935a8fe37ad204a63d85262fb5a66cef949bb0e5
580 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
935a8fe37a |
refactor(frost): run a signing session as k FROST instances in lockstep
Phase 2 of docs/frost-batch-signing.md. Pure refactor: proposals still carry one event, the wire is byte-identical, and every test passes unchanged -- 344 jvmTest and 217 testDebugUnitTest, none of them edited in this commit. advance() now loops over FrostSigningItem rows rather than reading the first one. One nonce per item, one aggregate per item, one Session.create per item, one partial signature per item, one signature per item. The signer set, the public shares, the tweak cache and the approval stay shared, because they are the terms that do not enter e = H(R‖P‖m). The coordinator's aggregation is the place where that distinction bites: it builds one AggregatedNonce per item, each from that item's nonce from each chosen signer. Reusing one across two items would be reusing R across two messages. ## The payload codec, early joinPayload/splitPayload land here rather than with the wire change, because at a batch of one a comma join is the identity -- the payload is the bare value it has always been. That leaves Phase 3 to the proposal encoding alone. splitPayload is strict: a payload that is not exactly the batch's length is dropped rather than truncated or padded. It runs in orderedNonces, orderedPartialSignatures and splitForSession -- never in record(), which stores payloads without parsing them so that a nonce can arrive before the proposal that would give it a length to check against. ## Two short-circuits, and one trap in the first advance() runs on every arriving message, so at a batch of k it was k native key generations, k Session.creates and k signs each time, usually to discover there was nothing left to do. - Nonces are generated by `lazy`. The obvious version -- a guard computing `ownNonce == null || (isSigner() && ownPartial == null)` -- is wrong, and wrong in a way that reads fine and fails every signing test: the coordinator settles the signer set further down the same pass, so isSigner() at the top is false on exactly the pass where the coordinator goes on to sign, and the nonces are never generated. Reproduced as IndexOutOfBounds before switching to lazy, which has no prediction to make. - A device that is neither signing nor aggregating leaves before building any FROST session, rather than building k of them to do nothing with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dff41d417d |
feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the next phases follow. Schema only: a session still signs exactly one event, the wire is byte-identical, and every existing test passes on the moved columns. ## What moved, and why it had to A batch of k events is k independent FROST instances sharing a signer set, not one signature over k messages. That is forced rather than chosen: a Schnorr partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under one nonce R give two equations in one unknown and the secret share falls out. So the five columns that enter that equation -- unsignedEventJson, eventId, nonceRandom, aggregatedNonce, signature -- move to a child table keyed (sessionId, itemIndex). What stays on FrostSigningSession is everything outside it: the ceremony, the threshold, the derivation path, the signer set, and the one approval. itemIndex is protocol rather than presentation -- nonces and partial signatures are joined positionally against it -- so getItems() orders by it and nothing re-sorts. Spelled itemIndex rather than index to keep hand-written queries free of backticks. No itemCount column. The count is a COUNT(*), for the same reason signerIds is derived from the ceremony's participant order rather than stored: a denormalised count is one more thing that can disagree with the rows. ## Migration 9 -> 10 Manual, not auto: Room can create the table and drop the columns but cannot copy between them, and the copy is the whole point. A session in flight at upgrade holds its nonce seed and the aggregate it is already signing against, and neither can be regenerated -- losing either makes the next pass derive a different nonce for the same message and publish a second partial signature over it, which is the extraction case. Both are copied verbatim into item 0, so an in-flight session resumes as though nothing happened. Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires cascades -- with foreign keys enforced the rebuild would delete every signer message and every item just written. Whether it does depends on Room disabling foreign keys around migrations, which is not worth depending on when DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed, unconstrained columns; these five qualify, and getRoomDatabase pins BundledSQLiteDriver on every platform. ## Invariants established here for the phases that follow - signerIds and every item's aggregatedNonce are one write-once unit, applied by applyAggregate() -- items first in one transaction, then the session, so "some items aggregated" is unreachable and signerIds != null stays the gate. - Signatures likewise, via applySignatures(); isSigned() counts rows instead of reading a flag. - complete() verifies every signature before applying any event, so a batch is all-or-nothing rather than half-filed. - itemsOver() gives each item its own 32 bytes of seed. Independent seeds mean an off-by-one in index handling produces a session that fails to aggregate rather than one that signs two messages under a single nonce. signedEvent() and isAwaitingApproval() now take the item(s) rather than the session, which propagates to the repository, the view model and the screen. advance() reads items.first() and Phase 2 turns that into a loop. ## Tests - FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert replacing rather than accumulating, signed-item counting, cascade delete. - FrostSigningItemMigrationJvmTest (new): the backfill against a real v9 database, asserting the seed and aggregate values survive -- not merely that a row appeared -- plus the exact column lists Room will check at open time. - 338 jvmTest and 217 testDebugUnitTest pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a805455df8 | Merge branch 'mantra' into claude/groupkeystate-frost-proposal-206090 | ||
|
|
44127cf514 |
test: exercise MarmotOutboundDao past the MLS guard
|
||
|
|
ed7a866421 |
test: run a real signing session between two devices, over two databases
|
||
|
|
5fa0d08dfd |
test: cover nip17 room derivation and its preconditions
A NIP-17 room has no MLS group, no key packages and no invites -- membership *is* the p-tag set on each message. Two things follow, and both are load-bearing. The room id is deriveChatRoomId over the member set, the same aggregate the inbound path derives from an arriving gift wrap. That is what makes creation idempotent, and idempotence here is not a nicety: two people starting the same conversation have to land on one room, or the thread exists twice with each side writing into its own copy and neither seeing the other. Covered from three angles -- the order members are named in does not change the id, creating the same conversation twice reuses the room as it stands rather than rewriting it, and a different member set derives a different room. The order-independence test is guarding `deriveChatRoomId`'s own `.sorted()`, not the DAO's `.toSet()`, and its comment now says so. That was established by mutation rather than assumed: rebuilding the member set as an order-preserving LinkedHashSet in the DAO changes nothing, because the derivation sorts anyway, while removing the sort fails the test. The distinction matters for anyone reading the DAO and concluding the set is what does the work. And `mlsGroupState = null` is what marks the room NIP-17. sendChatMessage reads exactly that field to choose between a kind:445 group event and per-recipient gift wraps, so a room that acquired MLS state would have its messages routed down a path no recipient is running. Covered alongside: the creator is a participant of their own conversation even when not listed among the participants -- sealGiftWrapPayload walks that list to decide who to wrap for, so omitting the creator would send messages every other member could read and the sender could not -- and naming the creator among the participants does not produce a second row for them. One test records a precondition and an asymmetry. Participant.participantPublicKey is a foreign key onto Profile, so createNip17ChatRoom raises a SQLite constraint failure for a member this device has no profile for, while getOrCreateChatRoom, one method down, answers the same "never seen this user" situation by returning null. A caller treating the two alike gets an unhandled exception out of the first. That was found by writing the tests -- seven of them failed with SQLite 787 before every member was seeded -- and is pinned rather than seeded around silently. The remaining getOrCreate coverage: it returns the room already stored rather than overwriting it with the defaults passed in, stands one up for a user it has a profile for, and writes nothing at all when it does not. Real secp256k1 keys throughout, because deriveChatRoomId does point arithmetic and treats an off-curve value differently from a valid one -- hex filler would exercise a path users never reach. 11 tests. composeApp jvmTest is 309 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8a6cb81bf9 |
test: cover the FROST signing session state
The signing counterpart to the DKG coverage, and it differs in the way the DAO's own comment gives: "unlike a DKG a group signs repeatedly, so there is no single current one to observe". Sessions accumulate rather than replacing each other, which makes room scoping and ordering load-bearing rather than incidental. The duplicate-suppression property is the same and matters for the same reason. The composite key (sessionId, signerPublicKey, kind) is what makes a redelivered nonce or partial signature replace its predecessor rather than add a row, and countMessagesByKind is what decides that enough signers have answered. A second row for one signer lets a session cross its threshold while short a real participant, and the aggregation then runs over a signer set that was never assembled. Covered by resending a nonce with a different payload, and separately by giving one signer both a nonce and a partial signature and asserting the second does not overwrite the first -- the kind in the key is the only thing keeping those apart. Also covered: a session reads back by id with its stage intact and a missing id gives null; a room's sessions accumulate newest first, with the latest reachable on its own; sessions are scoped to their room, which matters because signing happens in the #admins room and a device can be in more than one -- a session leaking across would have a signer answering a request its group never made; counts are per session and per round; and a completed session keeps its signature, which is what a resume reads to avoid signing the same event twice. One test records a difference rather than a guarantee. FROST orders a round's messages by createdAt where the DKG orders the same query by participant public key. Arrival order is per-device, so this ordering is not canonical across the group the way the DKG's is. It is pinned as it stands rather than asserted to be right: whether it is deliberate is not something this change can settle, and a caller that needs a canonical signer order has to impose one itself. Worth looking at separately. 8 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
168d16c933 |
test: cover the DKG ritual state a ceremony is resumed from
Two properties here decide whether a ceremony can finish, and the compiler sees neither. A participant gets one message per round, and that is enforced by the composite key (sessionId, participantPublicKey, kind) rather than by any code that writes to the table. Rounds advance on countMessagesByKind reaching the participant count, so a redelivered message that added a row instead of replacing one would let the count reach the threshold while a member had still never been heard from -- and the ritual would proceed on a participant set it never assembled. Covered by resending a participant's message with a different payload and asserting the count stays at one and the payload is the newer of the two, and separately by writing the same participant into two different rounds and asserting neither overwrites the other. A key-holding session needs both halves. thresholdPublicKey without secretShare is a ceremony that produced a group key this device cannot sign against; secretShare without thresholdPublicKey is a share with no key to sign for. Either alone is a failed ceremony, and offering it up as a signing key means attempting to sign with half a result. Covered with all four combinations present in the table at once, asserting only the complete one comes back. Also covered: the live ritual for a room is the newest, because a group may have abandoned earlier attempts and a resume that picked up an abandoned one would wait forever on participants who have moved to the newer; rituals belonging to another room are not offered as this room's; key-holding sessions come back newest first; and messages are counted per session and per round rather than across either. And the ordering, which is the one with a reason beyond tidiness: a round's messages come back ordered by participant public key, not by arrival. Every device has to assemble a round in the same order to compute the same thing, and arrival order is per-device. The test writes three participants in an order deliberately unlike the sorted one. Real secp256k1 keys throughout rather than hex filler, since these are the values a canonical ordering is defined over. Verified by mutation: relaxing the key-holding predicate to `OR` returns all three of the incomplete sessions and fails that test; reordering the round query by createdAt fails the canonical-order test. Both mutations were reverted; no production source is touched by this commit. 8 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a8f6638325 |
feat: sign a room's key state into being, at the room's own key
Two changes that turned out to be one. A room's key state stops being something its creator announces and becomes something the group signs, and every FROST signature moves from the group's root threshold key to the key derived at the room's own path -- which is the room's id. The second is what makes the first worth having: a key state is now signed by the very key it names. Supersedes the announcement introduced in |
||
|
|
baecb76253 |
test: cover the query a marmot reindex decides its work from
getResolvedMarmotGroupEventIds is what a reindex sweep subtracts from a room's stored group events to decide what to replay, so its answer decides what work the sweep does -- and both ways of being wrong are silent. Report an event as resolved when it is not, and the replay skips the one event that needed it: the message stays missing from the feed with nothing left to trigger another attempt. Report it as unresolved when it is resolved, and every sweep re-decrypts it forever. The whole distinction rests on `messageType NOT IN (:unresolvedTypes)`, where those types are the two placeholder lines that stand in for a message still to come rather than reporting one. Covered: an event with a real line is resolved; an undecryptable outer layer and a pending commit each leave their event unresolved, which is right because those are precisely what a replay exists to retry. Then the subtraction itself, since that is how the caller uses it -- three events, one settled, one holding a placeholder, one with no line at all, and the sweep left with exactly the last two. Covered because the query says so and nothing else would: `marmotGroupEventId IS NOT NULL` keeps out lines that are not about a group event -- a NIP-17 direct message, a locally written line -- which would otherwise carry nulls into a set the sweep subtracts with. And the room scoping, since a sweep runs per room and another room's resolutions must not shorten its work. Covered last, and it is the transition the sweep exists to cause: a placeholder upserted in place into a real line resolves its event, visible through this same query. Two smaller ones alongside: the single-row lookups order newest first, which is what makes them "the line for this event" rather than whichever row sqlite reached first, and the per-sender count is scoped to its room. Verified by mutation: defeating the messageType exclusion so placeholders count as resolved fails five of these, including the subtraction test. The mutation was reverted; no production source is touched by this commit. 8 tests. composeApp jvmTest is 282 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
521a4f5690 |
test: pin the epoch secret retention window
A retained epoch secret is what lets a member read a message sent under an epoch the group has since moved past. Both ways of getting the policy wrong are quiet: keep too few and old messages become permanently unreadable, keep too many and secrets that should have been dropped stay on disk. The entire policy is one strict `<` in a query and an IGNORE on an insert. The cutoff is strict, and that matters more than an off-by-one usually does. This query feeds a delete, so an epoch wrongly reported as droppable is not a stale read -- it is the messages of that epoch becoming undecryptable, with nothing to recover them from. Covered with three epochs either side of the boundary: only strictly older ones are droppable, the epoch equal to the cutoff is still inside the window, and a cutoff at or below every retained epoch drops nothing. Room scoping, for the same reason. Rooms advance epochs independently, so a sweep driven by one room's cutoff must never reach another's -- a leak here costs the other room its history. Asserted from both ends: the sweep returns only the sweeping room's rows, and the other room's secret is still there afterwards. Insert is IGNORE over the composite key (chatRoomId, epoch), which is what makes re-processing a commit safe. A redelivery or a replay re-derives the secret, and overwriting the stored one with that re-derivation would replace the value that actually decrypts the messages already on disk. Covered by inserting a second, different secret for the same epoch and asserting the first survives -- and alongside it, that the same epoch number in two different rooms is two rows rather than a conflict, since the composite key is what separates them. Also covered: defenestrate removes exactly the rows the sweep selected and leaves the rest, and a room's retained epochs are all readable back, which is what a rejoin or a full replay reads before deciding what it can still decrypt. Verified by mutation: relaxing the cutoff to `epoch <= :epochCutOffPoint` fails three of these, including the boundary test. The mutation was reverted; no production source is touched by this commit. 7 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d355b68355 |
test: cover the outbound broadcast queue and its stale sweep
Nothing else drains BroadcastNostrEventRequest, so a row this DAO fails to hand back is an event that never reaches any relay -- and the failure is silent, because a queue returning nothing is indistinguishable from an empty one. That already happened. The observer's predicate carried a `createdAt > :now` bound whose `now` was evaluated once, when the Flow was built. Instants persist at second resolution, so it hid every broadcast enqueued during the observer's own start second -- the entire profile-creation burst -- plus everything a previous session had left pending. There is a test here for exactly that shape: a row enqueued before the observer existed has to come back. The stale sweep, which is the other half of not losing events. A request is flipped to "processing" before a publish is attempted, and a timeout or a dropped socket leaves it there; nothing observes "processing" or "failed", so those rows are dead weight until the sweep requeues them. Covered: both stale statuses flip to "pending" and are counted; a row already pending is not touched, so the returned count is not inflated by work that was never stale. Covered separately, because it is the reason the sweep is bounded at all: a row newer than the cutoff is left alone. A publish running right now holds its row in "processing", and requeueing that would hand the same event to a second publish while the first is still in flight. The bound is `<=`, so a row stamped exactly on the cutoff second is swept -- asserted, since that is the boundary the second resolution of these timestamps makes common rather than rare. Also covered: the queue drains oldest first; a "processing" row is not handed out as pending work; and getFirstBroadcastNostrEventRequestByNostrEventId returns the oldest of an event's per-relay rows rather than the only one, since an event is queued once per target relay. One test is deliberately kept despite not being able to fail, and says so in its own comment. `requests sharing a timestamp drain in insertion order` pins the observable order of a same-second burst, which is what callers depend on -- but deleting the `, id ASC` tiebreak leaves it passing, because `id` is an autoGenerate primary key and therefore the rowid, so sqlite's unspecified ordering already coincides with it under this plan. That coincidence is the argument for keeping the explicit tiebreak rather than against it: it is not contractual, and an index or a different plan can change it. Recording the limit in the test seemed better than implying a guard that is not there. Verified by mutation: reversing the drain order fails the oldest-first test. Removing only the tiebreak fails nothing, which is how the limitation above was found rather than assumed. Both mutations were reverted; no production source is touched by this commit. 9 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
02e70d992a |
test: cover the MarmotOutboundDao membership guards
Both entry points that change a group's membership start by restoring the MLS state off the ChatRoom row, and a room restored from an inbound gift wrap has none -- there is nothing to add a member to. The comment on inviteMemberToChatRoom says the throw exists to "say so instead of silently doing nothing and letting the caller report success", which is a claim about behaviour and therefore something a test can hold to. A guard that returned quietly would still compile, still look like it worked, and leave a room whose members believe someone was invited who was not. Covered: inviteMemberToChatRoom and addMembersToChatRoom each raise MarmotMissingChatGroupException against a room whose mlsGroupState is null, which is exactly the shape a gift-wrap-restored room has. Covered separately, because ordering is the substance of it: a refused invite leaves no Participant row behind. The guard has to run before that write, not after. sealGiftWrapPayload walks a room's participants to decide who to wrap a Welcome for, so a participant persisted by a failed invite would make the room look like it has a member no MLS group knows about -- and the next Welcome would be sealed for them. Covered last: the empty-batch guard returns before the MLS state is looked at, so addMembersToChatRoom with no peers must *not* throw on the same stateless room the other two tests reject. Adding nobody is not a failure to add somebody, and pinning that keeps the two guards from being collapsed into one. Deliberately not covered, and the test file says so rather than implying the DAO is done: everything past the guard -- the MLS commit, the Welcome, the epoch advance and persisting it back to the room -- needs a real peer key package, which means an MLS fixture this change does not build. That gap includes the batching rationale on addMembersToChatRoom, which is the more interesting property of the two: one commit and one Welcome so no member ever has to process a commit for an epoch they were not yet in, since MarmotInboundManager refuses future-epoch messages outright with no queue and no replay. Worth covering once there is a fixture to build a key package with. The MarmotKeyPackage these tests pass carries an empty byte array, which is honest: no test here reaches the MLS layer, so the bytes only have to exist. A test that got past the guard could not use it. 4 tests. composeApp jvmTest is 258 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ba0c60dd2e |
test: cover the NostrDao event funnel and the publish durability split
NostrDao is what every event passes through, inbound and outbound, so its two decisions carry everything downstream: which of two copies of an event wins, and what survives when the enrichment after a write fails. Both were described in comments and neither was asserted. Deduplication, at all four positions. A first sighting is stored. A strictly newer copy replaces the stored one. An older copy is ignored. And -- the case that actually distinguishes the implementations -- a redelivery carrying the *same* timestamp is a no-op, because the comparison is a strict `>`. That last one is not hypothetical: relays redeliver and negentropy re-syncs, so the common case is the same event arriving again unchanged, and a `>=` there would rewrite the row on every delivery. The publish durability split, which is where a bug shipped. `commitPublishedNostrEvent` is the durable half -- mark the unsigned row signed, store the event, queue a broadcast per relay -- and indexing is best-effort enrichment that runs in its own transaction. They used to share one, so any throw in indexing rolled back `signedAt` too. Because the notary drains one unsigned row at a time, that row was then re-selected forever and every event queued behind it went unsigned, including the MLS key package that is enqueued last. The test provokes the failure the way the code itself would fail: publishing with no target relays reaches `relayURLs.first()` inside the try and throws. It then asserts `signedAt` and the stored event both survived. The happy path is covered alongside it, asserting a broadcast request per target relay, so the durability test cannot pass by publishing nothing at all. Also covered: an event from an author with no profile leaves a "LOADING..." placeholder stamped GENESIS_AT rather than nothing, since that row is the only record that the pubkey was seen and needs fetching; and rescheduleBroadcastNostrEventRequests re-queueing a broadcast and re-linking it to the chat line when the event is a group message that has one, without inventing a relation when it does not. One test began as a wrong assumption and the schema corrected it. The "no chat line" case was first written against an event id that had never been stored, and failed with SQLite 787: BroadcastNostrEventRequest.nostrEventId is a foreign key onto NostrEvent. So the real invariant is that a broadcast cannot be scheduled for an event the caller has not saved; the test now stores the event and leaves only the chat line missing, and says so in a comment rather than quietly seeding around it. Verified by mutation: relaxing the dedup comparison to `>=` fails the same-timestamp test; removing the try/catch around indexing so the throw propagates fails the durability test. Both mutations were reverted; no production source is touched by this commit. Uses `runBlocking<Unit>` on the durability test because its last expression is an assertNotNull, and a test method that returns a value is rejected by the JUnit4 runner outright. 9 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
36a98c5928 |
test: pin the nip30303 store-and-submit invariant in MantraDao
Every `add*` on MantraDao does two things in one transaction: writes the entity and queues a SubmissionEvent carrying the same nip30303 event for the group. The part worth asserting is the one `rumorOf` exists for. An entity's id is computed by its `Mantra*.from*EventTemplate` factory. The payload's id is computed separately, in `rumorOf`, from the same template. The two are meant to produce the *same event* -- the row on disk and the payload on the wire, not two copies of one. Nothing enforces that: the factories live in different files, both compile independently, and both produce a plausible 64-character id. A divergence would surface only as a group that receives a submission whose payload matches nothing it can find, which is a long way from the two hash calls that disagreed. Covered, through the seam rather than by recomputing the hash: the submission records `payloadEventId`, and that value has to equal the id of the entity the same call returned. Asserted for a dialect and again for an artifact version, because store-and-submit is the convention every `add*` follows rather than something addDialect does on its own -- and the second one goes through the full foreign key chain, dialect then artifact then version. Also covered: The envelope is not the payload. A submission's own id is the SubmissionEvent's and must differ from the payload's, which is exactly why `deleteByPayloadEventId` exists -- a superseded nip30303 event cannot be un-queued by its own id, and if the two ever collapsed to one value that method would start deleting envelopes by accident. The submission is queued unprocessed, `marmotGroupEventId == null`. That null is what the outbound pipeline selects on to encrypt the row into a kind:445. Filed as processed it would be stored and never sent, and the group would simply never learn about the dialect while the local device showed it as added. A ChatMessage line is written, since the room's feed reads ChatMessage and an added entity that leaves no line is invisible to everyone including its author. Verified by mutation rather than assumed: making `rumorOf` hash a createdAt one second off the template's fails both invariant tests, with the ids compared in the failure output. The mutation was reverted; no production source is touched by this commit. 6 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
20d2547a34 |
test: cover the hand-written NostrEventDao queries against sqlite
Two of these queries carry a comment describing a bug that already shipped, and neither bug was the kind anything catches by running: a wrong WHERE clause is still a valid query returning a plausible list. Both corrected predicates are now pinned, so the next edit has to argue with a failing test rather than with a comment. getMarmotGroupEvents. The predicate used to read `expiresAt < :now`, which kept exactly the expired events and dropped every live one -- for the whole group-chat sync path the set handed to negentropy was the complement of the relay's. Covered with four rows at once: no expiry at all (always served), an expiry in the future (still served), an expiry in the past (gone), and an expiry landing exactly on `now`, which the strict `>` excludes. Also covered: the inclusive since/until bounds using events stamped on each bound, room membership filtering across two rooms, and newest-first ordering with a limit keeping the newest window. getMarmotGroupNostrEventsByChatRoomId. Ascending order, because a replay has to apply commits in the order they were sent and this is the one query in the DAO that deliberately orders that way. The test that matters most here is that an event which never reached the MarmotGroupEvent table is still returned -- that is the whole reason the query reads NostrEvent instead of joining the index, since an event whose indexing failed part way is precisely what a replay exists to pick up, and a join would skip exactly those rows. Asserted from both sides: the un-indexed event comes back from the replay query and is genuinely absent from getMarmotGroupEvents. The same query's LIKE over-match is pinned deliberately rather than asserted away. The DAO's comment calls it a prefilter and puts the burden on callers to confirm the event's own `h` tag, so a room id sitting in an `e` tag is expected to come back. Recording it in both directions means anyone tightening the query knows a caller may rely on the loose behaviour, and anyone loosening a caller's check knows why it was there. Also covered: getNostrEventByPublicKeyAndKind returning the newest row, which is what makes it correct for replaceable events rather than a coin flip; and the difference between the two write paths, where `insert` with IGNORE keeps the stored event -- correct when re-receiving an immutable event from a second relay -- while `upsert` overwrites it. Last, the paged reads are pinned as treating `since` exclusively, which is what makes them safe to call in a loop with the previous page's last timestamp as the cursor. That sits one query away from the inclusive bound in getMarmotGroupEvents on purpose: the two conventions are genuinely different, and a reader who assumes either holds throughout gets a skipped row or a loop that never advances. The expiry test was checked by mutation rather than assumed: restoring `expiresAt < :now` fails it alone, with "an event expiring in the future is still live". The mutation was reverted; no production source is touched by this commit. 11 tests. composeApp jvmTest is 239 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5623df530c |
test: execute the nostr filter query against real sqlite
NostrEventFilterQueryTest pins the SQL string the builder produces. It never runs that string, and the gap between "the SQL reads correctly" and "sqlite returns the right rows" is where this query's expensive mistakes live. Four classes of bug survive a string assertion intact, and all four are covered here. SQL that is well-formed but not accepted. The clause emitted for a present-but-empty list is the bare literal `0`. Whether sqlite takes that as a false boolean expression rather than rejecting it is not something the builder test can answer; ids, authors, kinds and tags are each asserted to match nothing when handed an empty list. Binding indices. The limit is bound after every tag pattern, so its placeholder is the last in the statement. A drift in that order produces a byte-identical SQL string and different rows, so it is covered by a filter that carries authors, kinds, since, until, search, a tag and a limit at once. LIKE semantics against the column as actually written. The tag pattern is a fragment of the encoded tag -- `["p","<hex>"` -- and only real stored JSON can show that it anchors on the tag name (a pubkey in an `e` tag is not a `p` match, which is exactly the regression the substring scan `tags LIKE '%<pubkey>%'` caused), that it tolerates the relay hint and marker that follow a real tag value, and that escapeLike keeps a `%` in a tag value literal instead of widening the match. Timestamp units. This is a cross-file invariant nothing enforces: NostrEventFilterQuery binds since/until as epochSeconds, and MantraConverters.instantToTimestamp writes the createdAt column as epochSeconds. They agree today. Move either to milliseconds and both files still read correctly on their own while the filter silently selects nothing or everything, so the agreement is now asserted directly. Also covered: the NIP-01 inclusive bounds on both ends, using events stamped exactly on since and on until -- the case that tells an inclusive bound from the strict `createdAt > :since` this replaced; tags ORing values within a name and ANDing across names, against tagsAll which ANDs within a name too; newest-first ordering with the `id DESC` tiebreak, and a limit keeping the newest rather than the oldest window the per-shape queries used to return. Three of these were checked by mutation rather than assumed. Reverting the tag pattern to the naive `%value%` substring fails `a tag value is matched in its own position`; relaxing `createdAt >= ?` back to `>` fails both the inclusive bounds test and the units test. The mutations were reverted; no production source is touched by this commit. 14 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9c50b4fcee |
build: advance lightning-kmp-app onto the jvm target test coverage
Moves the pin from 05ce7eb to 01489b8, five commits on the submodule's master:
one fix and four test files covering the jvm target the previous pin move
restored.
The fix is a branch-ordering bug in defaultApplicationDir. It matched os.name
against "win" before "mac"/"darwin", and "darwin".contains("win") is true, so a
darwin os name resolved to AppData/Local/phoenix rather than
Library/Application Support/phoenix. Nothing a stock JDK reports is affected --
macos says "Mac OS X" -- but that directory is what SeedManager resolves
"node-data" against, so the failure mode was a node seed written where no
correctly resolving run would look for it.
The tests take the jvm target from 3 test files to 7, adding 31 tests over the
directory contract and os layout of PlatformContext, the seed-decryption
exception mapping in TechnicalExtensions, the NetworkMonitor start/stop
lifecycle that AppConnectionsDaemon gates every connection on, and the
platform-specific one-line actuals. The submodule's jvmTest suite is 128 tests,
0 failures, 3 skipped -- the skips being the pre-existing @Ignore'd
ElectrumServersTest, which is a manual check against live servers.
No composeApp source changes: this is submodule coverage only, and the pinned
library's public surface is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f600c9f87a |
build: pin lightning-kmp-app back to the jvm-target branch
Restores the pin to 05ce7eb, the tip of `claude/jvm-target-actuals`, undoing
|
||
|
|
a00fc1775c |
build: pin lightning-kmp-app to master instead of the jvm-target branch
The submodule was pinned at 05ce7eb, the tip of `claude/jvm-target-actuals`. Move it back to 6434282, the master tip it branched from, which carries the AGP 9.4.0 / jni-android substitution work but none of the jvm actuals. Dropping those four commits from the pin removes the library's jvm target (BusinessManager, the jdbc DbFactory, JvmKeyStore, the polling NetworkMonitor and their tests). The jvm work is not lost -- it stays on `claude/jvm-target-actuals`, pushed to origin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
af81933ab4 |
Merge branch 'mantra' into claude/room-db-testing-setup-b053cd
Brings the branch up to date with the 40 commits mantra gained while the jvm target was being built, so that merging the other way is a fast-forward. One conflict, in docs/README.md, where both sides added rows to the index table. Kept both, and gave the jvm-target note a clause in the closing prose since it is the one document there that is not about the protocol. One thing the auto-merge could not have caught. `9250991` added NostrEventDao.getMarmotGroupNostrEventsByChatRoomId as a blocking query, which android accepts and which Room refuses to generate for any other target -- so the merged tree failed :composeApp:compileKotlinJvm with the same "Only suspend functions are allowed in DAOs declared in source sets targeting non-Android platforms" that phase 4 dealt with 58 times. Made suspend; its only caller, NostrDao.reindexMarmotGroupEvents, was already suspend, so again no cascade. That is now a standing cost of this branch rather than a one-off: any DAO method added on mantra while this is outstanding will break the jvm build on merge. It is a one-word fix each time, and the compiler names the line. Verified on the merged tree: :composeApp:compileKotlinJvm and :composeApp:compileDebugKotlinAndroid green, :composeApp:testDebugUnitTest 208 passing, :composeApp:jvmTest 214 passing -- both test tasks re-run from scratch rather than taken from the cache. The jvm figure is larger than the android one because jvmTest inherits commonTest, so declaring the target quietly gained the whole shared suite a second execution environment. That is worth knowing independently of whether desktop ever ships: the same tests now run on the host, without an emulator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
607ef72bc3 |
Merge branch 'mantra' into claude/marmot-group-reindex-events-96a0d0
# Conflicts: # composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt # composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt |
||
|
|
925099125b |
feat: read a room's group events again when they arrived out of order
Relays impose no ordering, so a kind:445 can turn up before the group can read it: an application message encrypted under an epoch whose commit has not landed, or a commit for an epoch ahead of the local one. Both are stored and then dropped -- MarmotInboundManager refuses an out-of-epoch commit precisely so it does not half-mutate the group -- and nothing goes back for them once the missing event fills the gap. The message is on disk, readable, and never read. A "Reindex Events" button at the bottom of the group's detail screen is that second look. Only events with nothing to show for them are replayed: no chat line at all, or one of the two placeholder types. A room where nothing went wrong is left exactly as it was, which is what makes the button safe to press on a hunch. Passes repeat while a pass recovers something, because created_at order is not epoch order and a commit recovered by one pass is what lets the next read the messages that were waiting on it. **Replaying was not safe as it stood.** Every row the path writes is keyed on an event id and upserts in place -- MarmotGroupEvent, MarmotInnerEvent, and the nip30303 entities -- with one exception. ChatMessage's primary key is autogenerated, so writing a freshly built line always inserts, and a re-read would have left the room showing each recovered message twice, once as "Undecryptable Message" and once as itself. ChatMessage.reconcileMarmotLine matches on the group event id instead, so a re-read is an update, and refuses to let a placeholder overwrite a line that says something. That last rule is what protects the line this device wrote on the way out for a message it sent: our own kind:445 cannot be read back, since the sender ratchet has consumed the generation, and without the rule a replay would have replaced our words with "Undecryptable Message". The MLS group itself was already safe to replay against, which is worth saying because it is the part that looks dangerous: a commit behind the current epoch is rejected as a duplicate before it touches the group, one ahead is refused, and a consumed ratchet generation throws before mutating anything. The exception was quartz's EpochCommitTracker, which does not dedupe and only empties when a commit applies -- so replaying a held commit just grew the list and left it pending forever. forgetPendingCommits drops the room's entries first, and the sweep feeds the events back in the order CommitOrdering picks a winner in, so a contested epoch resolves the same way it would have on every other device. **What is testable, and what is not.** The DAO is not: testDebugUnitTest is plain JVM and Room's in-memory builder wants an Android Context. So the two pieces carrying decisions are lifted out where they can be run without one -- MarmotReindexSweep for the stopping rule, and reconcileMarmotLine for which of two lines wins -- and the DAO is left as query, sweep, write. The filter tests pin why the query's `tags LIKE` is a prefilter and not a test: an event belonging to another room can mention this one in a q tag, and its own h tag is what rejects it. **Not recovered by any of this.** A message whose key is gone -- one the ratchet has already advanced past, or one from an epoch predating this device's join. And events that never reached disk at all: storeNostrEvent is a single transaction, so a kind:445 arriving before its room exists rolls back its own insert along with the failed indexing, and only a re-sync brings it back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6aff34c5c7 | Merge branch 'mantra' into claude/artifact-frost-signing-proposal-a414f6 | ||
|
|
786c0602da |
feat: sign an artifact into the library instead of submitting one
Adding an artifact no longer creates one. It opens a signing session over an ArtifactEvent, and the artifact appears -- on every member's device at once, authored by the group's shared key rather than by whoever typed it -- when enough members have signed. The same trade the dialects made: a submission says "I am putting this in front of the group" and the group's only recourse afterwards is social, while a signature is the group saying it and it takes a quorum to say. A library is the group's. **The first version.** This is the part the dialect had no answer for. An artifact was creating an initial ArtifactVersion as a second submitted event, and that cannot survive the change: a chapter attaches to a version rather than to an artifact, so an artifact without one is inert, but a version cannot be submitted before the artifact it points at exists, cannot have its own quorum without costing a second signing session per form, and cannot be invented locally -- an invented id differs on every device, so members would silently disagree about which version a chapter hangs off while every screen showed the same artifact. So the label rides on the artifact as an `artifactVersion` tag and the row is derived from the signed artifact's own fields when it is applied. Same bytes in, same row out, everywhere. It is a rumor, because nobody signed it; what the group signed is the artifact that declares it. **What went away.** MantraDao.addArtifact and its way up through the repository. Nothing called it once the screen proposed instead, and leaving a path that authors an artifact under a member's key while the UI insists on a quorum would have double-created the version besides. **Tests.** Three files, and each was checked against a broken implementation rather than only against a working one: deriving the version from the clock, dropping the label from the proposal, authoring the derived row as its reader, and losing the signature on the way out of the session are all caught. SignedArtifactTest runs a real 2-of-3 quorum over an actual proposal, because the claim worth holding -- the row is the group's, and carries proof of it -- is invisible when it breaks. Not covered: applyInnerEvent's two upserts, which need a database no test here stands up, and AddArtifactViewModel, which is plumbing across two dispatchers over a template the tests already pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
adf1f03817 |
feat: a desktop entry point, and the first code here that runs
Phase 5. `press.mantra.desktop.MainKt` has been named by the compose.desktop block since before this work started and did not exist; now it does, and `./gradlew :composeApp:run` opens a window. **The window opens onto a passphrase gate, not onto the app.** That is phase 3 landing here rather than there, and it was not in the plan. keyStoreEncryption(keyName, plainText) takes no secret, because on android the OS keystore serves keys without asking anybody anything -- so a passphrase scheme needs an unlock the expect signature cannot express. MainKt calls JvmKeyStore.unlock before MantraApp is composed, off the ui thread, because Argon2id at 64 MiB is deliberately slow enough to stop the window painting. The gate says on its face that this build is not for real funds. One application directory is handed to both the mantra and the phoenix context, so a single install keeps a single place on disk rather than two named after different projects. **MantraDatabaseJvmTest is the part worth keeping.** Running the app proves the window paints; it proves nothing about Room, because the gate stops before anything touches the database. Six tests now open it: the schema is created, a profile survives a write and a read, upsert replaces rather than duplicates, the @Transaction relation query behind findChatRoomById reads back, a soft-deleted room stops being found, and the on-disk builder writes under the context directory rather than java.io.tmpdir. This is the first time this database has been opened anywhere but android, and it covers exactly what the compiler cannot see -- that Room's ksp output for this target is usable, that the *host* SQLite native loads where the android artifact's would not, and that the 58 queries forced from blocking to suspend still return what they stored. Both of that test's first drafts were wrong in ways worth keeping the scars of. Every write failed with SQLite error 787 because Profile has a foreign key onto NostrEvent and the test never created the parent row -- which is evidence rather than an annoyance, since a schema whose constraints were quietly off would have let all of it pass. And Kind is a typealias for Int, not a constructor. Window sizing is 480x900: a starting size that does not immediately misrepresent layouts only ever exercised at phone widths, not a considered desktop layout. That, along with back handling and any ui offering an nfc affordance, is the shakeout this phase names and does not do. Verified, all five green: :composeApp:compileKotlinJvm, :composeApp:compileDebugKotlinAndroid, :composeApp:testDebugUnitTest (52), :composeApp:jvmTest (6), and the fork's :library:jvmTest (97). Not verified: nothing past the gate. No seed has been written, no business started, no relay contacted. A gradle `run` killed with SIGTERM reports BUILD FAILED with exit value 143 -- that is the signal, not the app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
024da99404 |
test: pin what a member who never took part needs to finish a session
|
||
|
|
bf4041a5b2 |
feat: mantra compiles for the jvm
Phase 4. Declares jvm(), implements all 16 expects, and bumps the submodule to the fork branch carrying phases 1-3. :composeApp:compileKotlinJvm is green. **The actuals were the small half. Room was the blocker.** The first jvm compile failed with 58 copies of "Only suspend functions are allowed in DAOs declared in source sets targeting non-Android platforms". Room permits blocking query methods on android and nowhere else, so every @Dao function that was neither suspend nor Flow-returning had to change -- 58 of them across 24 files. KSP reports these in alphabetical batches, so the count shrinks in stages and looks bottomless; scanning the dao package directly for abstract funs with no suspend and no Flow return finds them all at once. It stops there, which is the only reason this is a 58-line change rather than a refactor. Every one of the 15 call sites outside the dao package was already inside a suspend function -- the repositories were written that way throughout -- so nothing needed rewriting. One private helper, DatabaseNostrRepository.matchNegentropicNostrEvents, had to become suspend, and its single caller was already suspend, so the cascade terminated immediately. Zero call-site edits. **The cost lands on android, not on the jvm.** A blocking DAO method runs on its caller's thread; a suspend one is dispatched to the query coroutine context, which getRoomDatabase sets to Dispatchers.IO. That is the better behaviour -- it is what stops a query running on the main thread -- but it is a real change to the shipping platform, made for a target that does not run yet. Hence the unit tests below rather than a compile alone. **BusinessManager was not an expect**, so nothing warned about it. It is now ported to the fork's jvmMain (05ce7eb); Phoenix.jvm.kt and NavigationViewModel.jvm.kt are otherwise the ios actuals with one changed import, since those files use no ios API. **schedulePlatformLogic schedules nothing, and logs that it does not.** Android starts two WorkManager jobs here, one of which is ChannelsWatcher -- it wakes periodically to notice a channel force-closed while the app was shut. A desktop application has no process once its window closes, so there is nothing to wake, and running the watcher in-process would be strictly worse than not running it: it would only fire while the app was already open and watching. The exposure is real and belongs in release notes rather than a comment -- a desktop wallet left closed past a force-close deadline does not notice. Smaller calls. PlatformContext carries an application directory, since there is no Context to read one from, and PlatformDatabaseBuilder puts aux.db under it rather than in java.io.tmpdir, which is what the abandoned Aux implementation did behind a TODO and which most systems clear on reboot. themeColorScheme ignores dynamicColor, which means Material You and has no desktop counterpart. AppVersion reads the jar manifest that compose.desktop writes, falling back when running from a class directory. Verified: :composeApp:compileKotlinJvm green, :composeApp:compileDebugKotlinAndroid green, and :composeApp:testDebugUnitTest 52 passing -- the one that matters, since this commit changes shared code every android query path goes through. Not verified: nothing has run. No jvm entry point exists yet, so the database has never been opened on this platform and no business has been started. That is phase 5, which also has to unlock JvmKeyStore before the wallet starts -- a passphrase prompt, not just a window. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b87e6e4ed5 | Merge branch 'mantra' into claude/frost-proposal-review-visibility-8f6fab | ||
|
|
e22a8ae4cd |
fix: stop asking a member to review a signature the group has settled
The transcript's "Review" affordance is a promise: tapping it leads to a
decision still there to be made. For a FROST signing proposal it was only
ever withdrawn one way -- and a proposal can be processed three.
**How a request was closed.** RitualNotice drops the tint and the call to
action when the request is answered, and a request counts as answered when
the step it asked for has since been published by this device:
FROST_REQUEST_FULFILMENTS = mapOf(TYPE_FROST_APPROVAL_NEEDED to TYPE_FROST_NONCE)
Approving publishes a nonce, so approving closes it. Nothing else does.
**Declining.** decline() fails the session and broadcasts a FAILURE. It
publishes nothing of the member's own, by design -- a refusal is a refusal.
So no fulfilment line is ever written, and the request went on asking, in
primary tint, for a decision the member had already made. Tapping it
reached a screen with no buttons on it, which was the screen being right.
**A quorum that did not need them.** A t-of-n key finishes without
everybody. The coordinator takes the first t nonces, and a member whose
phone was in a pocket is simply not among them -- but advance() returned at
the approval gate on their device, so the arriving SIGNATURE was stored and
nothing was done with it. Their session sat at COLLECTING_NONCES forever.
The request stayed lit, the screen still offered Sign and Don't sign, and
both answers were wrong: a nonce nobody was waiting for, or a refusal that
would flip a COMPLETE session to FAILED on every device and announce
"Nothing was signed" to a group holding the signature. fail() writes the
stage with update() rather than moveTo(), so that last one was reachable.
**The transcript.** A request is now closed by being *answered* or by being
*settled* -- a frostComplete or frostFailed line after it. The two are kept
apart deliberately. Answered keeps the tick; settled does not, because the
member never answered and crediting them with a signature they refused, or
were never asked for, is worse than the summons was. Both rules moved out
of the composable onto ChatMessage, where they are stated once and tested.
Settlement is signing-only: a ceremony step can only be taken or waited
for, so a DKG request has no equivalent and reading one from a signing
session's end would drop a summons the ritual is still stalled on.
**The session.** The transcript alone could not close the third case: the
device that never approved wrote no terminal line to read. advance() now
completes on a signature that has already arrived, ahead of the approval
gate rather than below it. That gate is there to keep this device's own
material off the wire, and finishing puts none there -- it verifies the
aggregate, applies the event and announces, all from what is already
stored. Everything it now skips on that path is work the signature made
pointless anyway: a late nonce, a partial signature nobody will aggregate.
Three things follow. isAwaitingApproval reports false, so FrostSigningScreen
hides the buttons -- it now asks the manager rather than re-deriving the
rule, which had drifted into a second copy of it. A late "Don't sign"
cannot abandon a signature that exists. And the signed event finally lands
locally for a member who never approved: applySignedEvent sat below the
gate and was being skipped, so a dialect the group signed without them
never reached their store.
Verified: :composeApp:compileDebugKotlinAndroid succeeds, and
:composeApp:testDebugUnitTest passes -- 165 tests, 16 of them new. Eight
cover the transcript rules against a hand-built row list; eight cover
isAwaitingApproval, including the settled-signature case. What stays
uncovered is advance() itself, which is Room-backed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
670f87a609 |
docs: record what phase 3 turned out to require
Phase 3 is implemented in the fork on claude/jvm-target-actuals (ce49657). The security analysis in the plan held up; three practical constraints around it did not appear until the code was written. **A passphrase-derived KEK is not a drop-in.** The plan treated the choice between a passphrase, an OS keychain and a key file as the whole decision. But keyStoreEncryption(keyName, plainText) takes no context and no secret -- on android the OS holds the key, so none is needed -- which means any passphrase scheme needs an out-of-band unlock the expect cannot express. That is a change to application startup, not just to the actual, so it is now called out against phase 5: the desktop entry point has to prompt and unlock before the wallet starts. **The iv must be 16 bytes.** EncryptedSeed.V2.serialize in commonMain throws on anything else, which rules out a conventional 96-bit GCM nonce -- worth knowing before designing around one. It turns out to help: with randomly generated nonces the risk is a repeat under one key, and 128 bits makes that vanishingly unlikely where 96 merely makes it unlikely. **Argon2id costs a dependency.** The jdk has PBKDF2 and no memory-hard KDF at all, so it means bouncycastle. Recorded with the reason to pay it: if the build is dev-only because it lacks hardware backing, weakening the KDF too gets the trade backwards. Also recorded: wrap a per-name data key under the KEK rather than encrypting the seed with it directly, so a passphrase change rewraps 32 bytes; throw java.security.KeyStoreException when locked, since the graceful* wrappers already map it to DecryptSeedResult.Failure.KeyStoreFailure; and a verification section naming the properties that fail quietly, plus the two limits worth writing down rather than fixing -- the first unlock on a new store accepts any passphrase, and zeroing the key is best effort. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
02117643c4 |
fix: send a group event because it was queued, not because the chat mentions it
No FROST signing message has ever reached another participant. The proposal
was built, MLS-encrypted, wrapped under the exporter secret, signed as a
kind:445, written to NostrEvent and MarmotGroupEvent, and its queue row
marked processed -- and then never handed to a relay, by a branch that was
never about delivery at all.
**The gate.** The tail of MarmotOutboundDao.encryptAndSendMarmotInnerEvent
looked up the transcript row for the queued rumor and did everything else
inside it:
val chatMessageOrNull = database.chatMessageDao()
.getChatMessagesByMarmotInnerEventId(marmotInnerEvent.id)
chatMessageOrNull?.let { chatMessage ->
... relation, marmotGroupEventId ...
val ids = database.broadcastNostrEventRequestDao().insert(...)
}
The BroadcastNostrEventRequest rows are the only thing that puts a kind:445
on a relay -- observeBroadcastNostrEventRequestsByStatus("pending") is what
the broadcaster watches, and nothing else inserts them for this path. So the
question "does the chat have a line for this?" was silently answering the
question "should the group receive this?".
**Why FROST always lost.** A signing message has no ChatMessage by design.
FrostSigningManager.broadcast queues the rumor alone, and announce() writes
its milestone lines with marmotInnerEventId = null on purpose: each device
writes its own transcript from the messages it has already received, so the
lines cost no traffic and cannot disagree with the session they describe.
The inbound half states the same intent from the other side --
ChatMessage.applyInnerEvent returns null for every FrostSigningEvents kind,
because a row there would be a second, worse account of what the manager
already narrates.
That is every kind in the family, not just the proposal: nonces, the signer
set, partial signatures, the finished signature and the failure notice all
go through the same broadcast(). A session could not have completed even if
a proposal had somehow arrived.
**GroupKeyStateManager.announce had it too.** Same shape, same silence: a
room's kind:30326 announcement of which key it signs with was queued,
encrypted and dropped.
|
||
|
|
506dfea802 |
docs: correct phase 2 against what the jdbc drivers actually needed
Phase 2 is implemented and verified in the fork on claude/jvm-target-actuals (d1a82ea). Three corrections and one omission. **Schema handling does not need hand-rolling.** The plan said "you call Schema.create(driver) and the migration path explicitly, and you have to track the applied version yourself". SQLDelight 2.x ships a factory function that shadows the constructor -- JdbcSqliteDriver(url, properties, schema, migrateEmptySchema, vararg callbacks) -- which does all three, user_version included. The same-named constructor does none of it, which is the trap worth naming rather than the work that was budgeted for. **Foreign keys were the actual work, and the plan never mentioned them.** Off by default in SQLite, and the pragma is per connection while JdbcSqliteDriver opens one per thread, so it has to go through the connection Properties rather than be issued once against the driver. Recorded along with why that needs no compile dependency on org.xerial:sqlite-jdbc, which arrives at runtime scope only. **commonTest has an expect too.** The 23 counted at the top of this document are commonMain's. Declaring jvm() also creates jvmTest, which inherits commonTest, so `connect` in ElectrumServersTest blocks every jvm test from compiling. Noted along with the reason not to stub it empty the way ios does: the class is @Ignore'd everywhere, so an empty body looks harmless right up until somebody removes the @Ignore and connect_to_mainnet_servers starts passing without connecting to anything. **Phase 2 is the first phase that can be run, and the plan told you not to bother.** It said "none of this is exercisable until Phase 4. Write the SQLDelight schema-creation path against a scratch main() if you want feedback sooner." That was wrong twice: library/src/jvmTest/ already exists, and the two properties worth checking are exactly the ones a compiler cannot see. Schema creation and the foreign-key pragma both fail silently in production -- a missing table only shows up at first query, and foreign keys being off means cascading deletes quietly do not happen. The phase now carries a real exit condition, and DbFactoryJvmTest meets it with five passing tests. Recorded with it: the two KeyStoreFunctions actuals have to exist before phase 3 decides anything, because nothing jvm compiles without them, and they should throw rather than do something plausible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0757e50dc5 |
docs: correct the jvm plan against what phase 1 actually did
Phase 1 is implemented and verified in the lightning-kmp-app fork on claude/jvm-target-actuals (27a0054). Four things in the plan were wrong, and doing the work is what surfaced them. **jvm() belongs at the start of phase 1, not phase 4 -- for the library.** The plan said leave it off in both builds until phase 4. That is right for mantra and wrong for the fork: library/src/jvmMain/ is an orphan source set until the library declares the target, so phases 1-3 would all have been written blind. Declared first, `:library:compileKotlinJvm` names the remaining expects, and that list beats grepping for `expect ` -- it shrinks by exactly what you implement and cannot drift from the truth. The build stays red across phases 1-3 by design. That checklist is now recorded as the phase 1 exit condition: exactly eight expects should remain, and exactly which eight. Anything else means something in the phase is wrong. **Phase 3 is two decisions, not four.** gracefulSingleSeedDecryption and gracefulMultiSeedDecryption are pure exception mapping into a DecryptSeedResult, and the exception they branch on is java.security.KeyStoreException -- a plain JCA type that exists on the jvm. Both are near-copies of the android actuals and need nothing settled first, so they move alongside phase 2. Only keyStoreEncryption and keyStoreDecryption are the security decision, and that part of the analysis stands. **The Fibonacci template must not be deleted.** The plan said to drop it "assuming nothing references them". Things do: generateFibi is exercised by template tests in commonTest, androidHostTest, iosTest, jvmTest and linuxX64Test, and JvmFibiTest asserts a value that depends on precisely the two properties fibiprops.jvm.kt defines. That file already satisfies two of the 25 expects, which is why the count was 23 missing rather than 25. Removing the template is five test files plus four fibiprops.* actuals, and it is a separate cleanup. **Phase 1 is fifteen actuals, not fourteen**, and two of them are not copies of android -- platformElectrumRegtestConf (10.0.2.2 is the emulator's alias for the host loopback; a jvm process is already on the host) and phoenixLogWriters (android routes kermit into slf4j because android tooling reads that back). Also recorded, because it cost time: a worktree cannot run gradle at all until the submodules are checked out *and* local.properties exists at five levels. Neither is version controlled, so a fresh worktree has neither, and the failure surfaces four builds down at :...:secp256k1-kmp:jni:android as "SDK location not found" rather than anywhere obviously related. Both builds were run: `:library:compileKotlinJvm` fails only on the known eight, and `:composeApp:compileDebugKotlinAndroid` still passes with the library's jvm target declared -- the check that matters, since a new variant must not change how the android target resolves the library. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2aaa7b99a6 |
build: phase 0 of the jvm target -- clear the ground, correct the plan
First phase of docs/jvm-target.md. Nothing here turns the target on; it
removes what would break the moment it is turned on, and stages the two
catalog entries that cannot be derived automatically. Two of the four
steps as written in the doc turned out to be wrong, and implementing them
is how that surfaced -- both are corrected in the doc in this commit.
**Deleted the stale jvmMain tree.** Six files under
composeApp/src/jvmMain/kotlin/ac/cord/auxiliary/ survived from the Aux
project this codebase grew out of. They have gone unnoticed because
`jvmMain` is currently an orphan source set -- the accessor creates it,
no target compiles it -- so the wrong package, the Room 2 imports
(androidx.room, not androidx.room3), and the references to a long-gone
AuxDatabase and AuxGlobal have never had to resolve. They would all become
compile errors in phase 4.
They are not lost: they are the closest thing to a skeleton for five of
the six platform actuals phase 4 needs, and main.kt is a reasonable
starting shape for the phase 5 desktop entry point. `git show HEAD~1` has
them.
**Added two catalog entries, not four.** sqlite-bundled-jvm and
sqldelight-sqlite-driver. Both earn their place by being unreachable
otherwise: sqlite-bundled-jvm has to be named explicitly because
variant-aware resolution hands the *android* artifact to anything running
on the host, and sqldelight-sqlite-driver is the jvm counterpart to the
android-driver and native-driver entries already there.
The doc also listed room3-runtime-jvm and sqldelight-jdbc-driver. Neither
is right. Once jvm() exists, commonMain's existing androidx-room3-runtime
resolves to the -jvm variant on its own, so an explicit entry is
redundant and would drift. And the SQLDelight drivers phase 2 needs are
for DbFactory, which lives in lightning-kmp-app -- a separate gradle build
with its own version catalog, where an entry here is simply not visible.
**kspJvm cannot be wired yet, and the build file already said so.** The
doc's phase 0 told you to uncomment
composeApp/build.gradle.kts:194. It contradicted its own phase 4, which is
where jvm() gets turned on. The comment three lines above it states the
rule:
These configurations only exist when the ios targets are declared,
which the kotlin block above does only on a mac.
The same holds for kspJvm -- `dependencies { add("kspJvm", ...) }` throws
UnknownConfigurationException until a jvm() target creates the
configuration. So it moves into phase 4, into the same edit that declares
the target. composeApp/build.gradle.kts is deliberately untouched by this
commit.
**Also documented: gradle does not run in a worktree here at all** until
the submodule is checked out, which worktrees do not do automatically.
`lightning-kmp-app/` is empty and configuration fails with "Project with
path ':library' not found in build ':lightning-kmp-app'". Recorded in the
phase 0 verification section along with the caveat that a linked worktree
shares .git/modules/ with the main checkout, so both trees end up on one
submodule git dir.
**Not verified by a build.** For that reason. The deletion is an orphan
source set and the additions are unreferenced catalog lines, so neither
can change a build's outcome -- but that is an argument, not a green
check, and it is the second commit in a row on this branch that has not
compiled anything. Phase 4 is the first phase that genuinely cannot be
done without a working gradle invocation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
65e4a3acc0 |
fix: seal the Welcome, the one gift wrap an MLS room must publish
No invite to a Marmot room has been delivered since |
||
|
|
5abc37e463 |
docs: scope the jvm target, and separate it from testing the daos
Two questions arrived together -- whether Room's own testing guidance applies to this project, and what desktop support would cost -- and they turned out to have opposite answers. Both are now in docs/jvm-target.md, phased, with the blocking work separated from the mechanical work. **The expensive part is already done.** The four-deep native chain -- secp256k1 -> bitcoin-kmp -> lightning-kmp -> lightning-kmp-app -- already builds for JVM, on every android build we do. The comment at composeApp/build.gradle.kts:50 records the mechanism without drawing the conclusion: lightning-kmp-core publishes no android variant, so our android target resolves it to the *jvm* one, which pulls secp256k1-kmp-jni-jvm desktop natives, which is exactly why the build has to name the android artifact by hand. Read the other way round, every JVM artifact in the chain is already compiled from source by the composite build. A jvm target adds no cinterop, no C compilation and no new native constraints. That was the part worth being afraid of, and it is finished. **The blocker is one level down, and smaller than it looks.** lightning-kmp-app/library declares 25 expects and implements them across 35 androidMain files. Its jvmMain holds exactly one: fibiprops.jvm.kt, the Kotlin multiplatform library template's Fibonacci boilerplate, satisfying two of the 25 -- both of them the template's own. So 23 actuals are missing, which is why jvm() is commented out there (library/build.gradle.kts:18), which is why it is commented out here (composeApp/build.gradle.kts:46). Mantra cannot declare the target until the fork does. Six phases, ordered by that dependency. 0 build config; 1 the fourteen mechanical phoenix actuals; 2 the three SQLDelight JDBC drivers and NetworkMonitor; 3 key storage; 4 mantra's own sixteen expects; 5 the desktop entry point. 1-3 are independent and parallelisable, 4 is where the compiler finally checks the whole thing. Roughly a week to a launchable build. **Phase 3 has no day estimate, deliberately.** keyStoreEncryption / keyStoreDecryption and their two graceful* wrappers delegate on android to KeystoreHelper.kt -- 116 lines against AndroidKeyStore, StrongBox attempted first and fallen back from, key material never leaving hardware. Desktop JVM has no equivalent, so this is a decision rather than a port, and the doc gives the three real options against what each actually protects. A fixed-key JCEKS file is named there as a liability rather than a stopgap: this is wallet seed material, and it lands on top of the plaintext-key finding already open against this codebase. Recommended sequencing is a passphrase-derived KEK with the desktop build marked unsuitable for real funds, so phases 4 and 5 can proceed without the security question being quietly treated as answered. Two inherited mistakes are called out rather than carried forward. The old Aux jvmMain put the database in java.io.tmpdir behind a TODO -- the doc says not to inherit that in either phase that touches it. And schedulePlatformLogic goes through WorkManager on android with no desktop counterpart, so the doc asks for an explicit choice between a no-op and an in-process coroutine, written down. **The DAO answer is an appendix, because it is the opposite answer.** None of the above is needed to test the DAOs, and burying that would have been misleading. room3-runtime-android:3.0.1 already exposes the no-Context inMemoryDatabaseBuilder(Function0<T>) overload, and MantraDatabaseConstructor already supplies what it needs, so Room's recommended host-machine form compiles in commonTest and runs under testDebugUnitTest today. The one trap is native and is the secp256k1 problem mirrored: sqlite-bundled-android ships only android-ABI .so under jni/, so a local unit test's JVM cannot load it and BundledSQLiteDriver fails at construction; sqlite-bundled-jvm on the androidUnitTest classpath is the fix. Robolectric neither helps nor is needed -- it cannot load android .so on the host either. Everything structural here was checked against the artifacts rather than recalled: the Room builder overloads by javap on room3-runtime-android, the two sqlite-bundled native layouts by unzipping both, and the availability of room3-runtime-jvm, room3-testing, quartz-jvm and the two SQLDelight drivers by request against the repositories this build actually resolves from. The absence of android.* and java.* imports in commonMain, and of any NFC reference from it, was likewise grepped rather than assumed. **Not verified: anything that requires compiling.** No jvm target was turned on, nothing was built, and the day estimates are estimates. Phase 4 is where dependency-substitution surprises would surface if there are any, and it is precisely the phase nothing here exercises. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
39eac61838 |
Merge branch 'mantra' into claude/long-running-chat-sync-8983dc
mantra had moved on ~30 commits, several of them in exactly this area — and it turns out both branches independently found the same bug and drew the same conclusion about the same filter. **The overlap.** |
||
|
|
bcdfd2ec94 |
Merge branch 'mantra' into claude/marmot-direct-message-type-7a0473
Twenty-two commits had landed on mantra since this branch left it, several of them in the same files. Merged this way round so mantra stayed untouched until the result compiled and its tests passed. The migration had to be renumbered, and this is the conflict that mattered. mantra is at database version 7 and already has its own 5.json -- for MarmotInnerEvent.payloadEventId, nothing to do with direct messages. This branch had also written a 5.json, for a different schema. Resolved by restoring mantra's 5.json untouched and moving the direct message columns to an AutoMigration(7, 8) with a regenerated 8.json. Taking either 5.json over the other would have left every device validating a migration chain against a schema it was never built from; keeping version = 5 would have made a v7 install refuse to open at all. The regenerated 8.json is two ADD COLUMNs and nothing else, same as before. fromGroupEventResult was restructured on mantra: the kind switch moved into applyInnerEvent, and a SubmissionEvent envelope now wraps nip30303 payloads. Took that structure and re-applied the direct message branch ahead of it rather than inside it -- a gift wrap is not a nip30303 payload to apply, and what happens to it depends only on whether this device's key opens it, so it does not belong in a function about applying submissions. The isUserMessage fix was re-applied to the eight call sites mantra's version has, up from the six it had here. ChatMessageListViewModel and ChatRoomMessagingScreen took mantra's versions with the composer state, the two renderings and the reply action layered back on. docs/README.md keeps both new rows and mantra's closing note about the skipped-keys document. 108 tests pass, up from 50 here and 83 on mantra. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a909108300 |
feat: announce which key a room signs with, instead of rederiving it
A signer holds a different secret share under every ceremony it took part in, and signing with the wrong one produces a partial signature that cannot aggregate. Nothing said which was which: FrostSigningManager found a room's key by walking every ceremony this device holds a share for and rederiving each one's room id until one matched. That search can only find rooms derived at the one path the constant names. SharedKeyDerivation.parsePath was written to lift that limit and was never called, so a room derived anywhere else was invisible to signing. So the coordinator now says it. GroupKeyStateEvent (kind 30326) carries the threshold public key, the ceremony that made it and the path the room's id came from, posted into the room as its first application message and filed as a GroupKeyState row. completedKey reads that row first and follows it to the share. Nothing secret travels. Every member of the room can read the event, so a share on it would be each member holding everyone else's -- a 1-of-n key wearing a t-of-n's clothes. The event names the ceremony; the share stays in DkgSession.secretShare on the device that generated it. The coordinator is untrusted, as everywhere else in the ceremony, so a state is verified rather than believed: the room's id *is* the threshold key derived at the path, and one that does not rederive its own room is dropped. That is the same guarantee the rederivation gave, kept rather than traded for a lookup. The old scan stays behind it for rooms that predate the table. Announced after the members are added, which is the only order that works -- adding them commits a new epoch and MLS will not let a member read what was encrypted before the one they joined at. A member invited later still misses it and falls back to the scan, which is where every member was before this existed. Replacement is this app's job. These are rumors inside a Marmot group event, so no relay applies the 3xxxx rule, and the DAO keeps the newest announcement per room so a backfill cannot walk a room backwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c8cbd936f1 |
docs: record where the sync's safety net is, and where it is not
Two updates after the test pass. long-running-sync.md gains a section naming what each test file pins and, more usefully, the three things they cannot reach: NostrSocketClientImpl's reconnect loop and ordered inbound (exercised only through their extracted arithmetic — covering them wants a fake WebSocketSession), everything downstream of saveNostrEvent (Room-backed, and there is no sqlite driver on the JVM test classpath), and the app on a device. The manual checks stay the manual checks. It also records that the tests were verified by mutation rather than by passing, so the next person knows the assertions were confirmed to bite. dead-code.md's line references are refreshed — the testability seams shifted most of them — and it now says which commit they were correct at and to confirm with the grep rather than trusting them. One entry added: the DefaultNostrSocketClientFactory overload taking an explicit HttpClient has no caller now that everything goes through the interface method. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f5eb744ca7 |
test: cover the long-running sync, and open the seams needed to do it
The six commits that built the live chat sync added no tests. Everything they
touch fails silently by nature — a filter that drops messages, a subscription
that stops being replayed, a group whose id never reaches the `#h` tag — so the
symptom is always "some messages didn't arrive", days later, on someone else's
phone. 46 tests, in four files.
**What is covered**
RelayPoolSubscriptionTest (13) — the pool's half of surviving a dropped socket.
A query is retained and replayed on reconnect; a closed one is forgotten and
stops the socket reconnecting for it; closing one of two leaves the other alone;
a negentropy exchange is never replayed (its rounds are stateful, so resuming
one reconciles against a conversation the relay is no longer having); an update
to a live subscription replaces what gets replayed, including when the send
itself fails; dropping a relay or closing the pool forgets what they carried;
replay is scoped to the relay that reconnected. Plus the semantic the whole
change rests on, asserted in both directions: a live subscription keeps
delivering after EOSE, a one-shot query still ends at it.
LiveSubscriptionReconcileTest (12) — the requirement this all exists for: the
group filter follows group membership with nobody calling a subscribe function.
Joining widens the filter *in place* rather than reopening (a reopen would drop
the live tail of every other group in that chunk); leaving drops one; leaving
everything closes the subscription; churn inside the debounce window collapses
to one update; a NIP-17 room never becomes a group subscription. Then the
collect loop: events stored against the relay they came from, an event after
EOSE still stored, a CLOSED reopened once the back-off elapses and not before,
and a rate-limited CLOSED waiting far longer — but still coming back.
Backgrounding closes and foregrounding rebuilds, reconnects, and queues the
catch-up.
LiveSubscriptionPlanTest (11) — the filter and planning rules, led by the one
most likely to be "tidied up" later: the gift wrap filter carries no `since`,
because NIP-59 randomizes created_at into the past and a `since` near the
present silently drops new messages.
RelayBackPressureTest (4) and ReconnectBackoffTest (6) — the two pure decisions.
Which CLOSED reasons mean "ease off", and the backoff arithmetic including the
exponent clamp: 2.0.pow(4000) is Infinity and Duration * Double throws on it, so
without it a socket failing long enough turned its reconnect loop into a crash
loop, at the point the network was least likely to recover unaided.
**Seams opened to get there**, each a readability win on its own terms:
- NostrSocketClientFactory becomes an interface with DefaultNostrSocketClientFactory
behind it, so the pool can be driven by a fake socket.
- RelayPool takes its CoroutineScope, so the replay a reconnect triggers can be
observed rather than raced.
- LiveSubscriptionManager depends on a new LiveSubscriptionTransport (4
methods) rather than RelaysSocketManager, which observes the active wallet in
its init and cannot be stood up in a test at all.
- Its pure planning helpers move to the companion as `internal`, and its
launches inherit the caller's dispatcher instead of pinning Dispatchers.IO.
SynchronizationViewModel already launches observe() on IO, so nothing moves —
but a coroutine that picks its own dispatcher cannot be driven by a test
scheduler.
- reconnectDelay is extracted to ReconnectBackoff.kt with jitter as a
parameter, so the arithmetic can be pinned without randomness.
- endsLiveSubscription names the live-subscription termination rule next to
isTerminalFor, which is the one-shot rule. Having both named makes the
difference between them reviewable rather than implicit.
kotlinx-coroutines-test is added to commonTest: the pool's bookkeeping is all
suspend functions and there is no runBlocking in a common source set.
The tests were checked by mutation, not just by passing — reintroducing a
`since`, making EOSE terminal, dropping the leftGroupAt filter and removing
retention from query() each produce failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
0319f1613b | Merge branch 'mantra' into claude/nostr-event-save-issue-6e9467 | ||
|
|
5321e4af72 | Merge branch 'mantra' into claude/distracted-franklin-e95ba4 | ||
|
|
fb21678813 |
test: pin where a commit's bytes land when the row recording it is written
The mis-routed `framedCommitBytes` fixed in the previous commit was invisible for
one reason: nothing anywhere covered the persisted row. The bytes that reach a
relay come off the in-memory `CommitResult`, so the wire path stayed correct and
the stored path was wrong, and no test looked at the stored path.
## Why the mapping moved before it could be tested
A test that built `MarmotCommitResult` itself would have been writing its own copy
of the mapping and asserting against that. It would have passed against the buggy
code, because the bug was at the call site the test was not using.
So the mapping is now `MarmotCommitResult.from`, called by
`MarmotOutboundDao.inviteMember` and exercised directly by the test. That also
removes the shape that produced the bug rather than just the instance of it: the
old call site listed its named arguments in an order different from the
declaration, which is what put `preCommitExporterSecret` and `framedCommitBytes`
two lines apart. `from` lists the payload in declaration order, in one place, so
there is no second site to get wrong.
## What is covered
Four tests, each payload given a distinct self-identifying value so that a field
arriving in the wrong column names both halves of the mistake instead of comparing
equal by accident:
- every payload field lands in its own column.
- the framed commit column never holds the exporter secret -- the regression,
stated as an invariant rather than an equality so it keeps holding for a
`CommitResult` this test did not anticipate.
- a `CommitResult` that never framed its commit still stores a commit. quartz
defaults `framedCommitBytes` to `commitBytes` and the entity repeats that
default; the fallback must not quietly become the secret either.
- the bookkeeping `DatabaseNostrRepository` reads back on acknowledgement is
carried through. `id`, `chatRoomId`, `userPublicKey` and
`peerKeyPackageEventId` are all 64-char hex, so two of them swapped in `from`
would typecheck exactly as silently as the original bug.
Checked by reintroducing `framedCommitBytes = commitResult.preCommitExporterSecret`
into `from`: three of the four fail. A green suite that would stay green against
the bug it names is not coverage.
## What is not covered, and why
That the bytes published equal the bytes stored -- the property one level above
this one -- still is not. It needs the DAO, and the DAO needs Room: `commonTest`
carries only `kotlin.test`, the room3 KSP processor is registered for the android
and ios targets alone with `kspJvm` commented out, and `getInMemoryDatabaseBuilder`
wants a `PlatformContext` no unit test has. That is a Robolectric or instrumented
target, which is a larger change than this fix earns and is better decided on its
own merits than smuggled in here.
The ack-triggered rebroadcast that would have turned the bug into a live fault does
not exist yet, so there is nothing to test there either. When it is written, the
invariant it needs is already asserted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ad3304a665 |
refactor: build the DM inbox filter once, where it can be asserted
The filter fix a commit ago changed a value inline in a ViewModel, which is
not a place a test can reach: ChatMessageListViewModel needs a repository and
a coroutine scope to construct, and NostrDao needs Room. So the filter that
had just been wrong in three call sites went back to having no coverage at
all.
Nip17Filters.inbox is that filter with one definition. ChatMessageListViewModel
and ChatRoomListViewModel now both call it — they had been building it
separately and identically, which is also what made their negentropy requests
collapse into one under computeId, a coincidence better expressed as shared
code than left to hold by luck.
Nip17FiltersTest asserts every clause that was got wrong in production:
- the p tag names us, not a peer
- there is no authors clause, because a wrap is signed by the throwaway key
GiftWrapEvent.create mints and discards, so authors=[anything knowable]
matches nothing on any relay
- there is no since cursor, because NIP-59 back-dates a wrap by up to two
days and a high-water mark taken from the newest wrap we hold skips mail
stamped behind it — the trap waiting for whoever acts on the TODO in
NegentropySynchronizeRequest.toSynchronizeNostrEventRequest
- the wire JSON is pinned, so an added default cannot quietly split the two
callers back into separate requests
- the SQL NostrEventFilterQuery builds from it bounds no author either,
since negentropy is only as good as the agreement between the set we build
locally and the set the relay builds from the same filter
Neither of the two failure modes this covers was visible from reading the
filter. The authors clause failed silently for as long as it existed, and the
peer p-tag failed loudly but somewhere else entirely — in a Room transaction,
three files away, as a MAC error out of Nip44.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e1d35bbd6c |
test: pin who can open a gift wrap, and what happens to everyone else's
The Invalid Mac crash had no test standing between it and a repeat, so this adds one that reproduces it. GiftWrapMessageTest builds real NIP-59 wraps with real secp256k1 rather than recorded fixtures. The property under test is the key agreement itself — whether ECDH(ourPriv, ephemeralPub) can stand in for the conversation key the wrap was sealed under — and a fixture would only prove that the fixture still parses. Three cases carry the regression: - someone else's mail comes back null rather than throwing - not even the sender can reopen what they sent - isAddressedTo answers exactly what unsealing would Checked against the reverted fix, those three fail with the production exception verbatim (java.lang.IllegalStateException: Invalid Mac: Calculated bf2e6480…), while the two describing behaviour that never broke — the happy path, and isAddressedTo's reading of the p tag — stay green. A test that cannot fail against the bug it names is not worth the run time, so the split matters. The last of the three is the one guarding the fix's structure rather than its outcome. NostrDao decides whether to index on isAddressedTo, then throws GiftWrapUnsealException if decryptGiftWrapSeal returns null anyway; those two answers have to agree for either path to be correct. If they drift, the DAO either skips mail we can open or resumes rolling back transactions, and neither shows up as a failure anywhere near the change that caused it. commonTest gains kotlinx-coroutines-test for runTest. decryptGiftWrapSeal is suspending, runBlocking does not exist in common code, and every layer worth testing below the ViewModels — DAOs, repositories, the model's crypto — is suspending too, so the dependency pays for more than this file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
42dd38cfc4 |
test: pin the two invariants this session left unguarded
Both are silent when broken, which is why they are worth asserting rather than reasoning about. **The cache's reuse decision.** MlsGroupCache exists because quartz drops a secret tree's skipped-generation keys on save, so rebuilding a group between two messages loses any that arrives late. Its safety argument is one comparison: reuse while the stored state is still what the cache last wrote, rebuild when it is not. Get that wrong in either direction and nothing complains -- reuse too eagerly and a group carries on from a ratchet another writer already moved, which corrupts decryption rather than failing it; reuse too rarely and the cache does nothing and the original bug is back with no symptom. That decision is now a generic LiveInstanceCache with MlsGroupCache as a typed facade over it, so it can be tested without standing up an MLS group. Splitting it also made two behaviours explicit that were previously incidental: a failed build no longer leaves the old instance behind, and an instance whose use threw is deliberately not cached -- it is half-advanced and never persisted, so the next caller has to start from disk. **Rumor and row ids agreeing.** MantraDao writes an entity whose id comes from fromXEventTemplate and separately builds the rumor it submits with rumorOf, which hashes the template itself. Both are meant to produce one id and nothing checked it. Diverging would mean submissions naming an event nobody has, deleteByPayloadEventId silently un-queuing nothing so superseded translations go out anyway, and every receiver creating a second row instead of converging on the sender's -- all of it invisible, since the ids are opaque hex either way. Asserted per kind, plus the whole chain out through the submission envelope. Both suites were mutation-checked rather than trusted: inverting the staleness comparison fails one cache test, recording the pre-block state fails another, and hashing the rumor under a different author fails all six id tests. Still uncovered, and not cheaply fixable: FrostSigningManager's and MantraDao's state machines both need a Room harness, and commonTest has none. The FROST crypto path is covered by FrostSigningRoundTest; the message-driven parts around it are not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c24cbed390 |
docs: record what the coverage work found, and what it left uncovered
Three additions. The decision the inbound path makes now has a name and a home -- MarmotDirectMessage.classify -- and the doc says why it is separate from the filing of it: only the filing needs a database, so splitting them is what lets the check that replaces MIP-03 be tested at all. A security property found while writing those tests, which I had asserted backwards. Relabelling a seal with another member's pubkey does not get as far as the signature check: NIP-44 derives the conversation key from the pubkey being claimed, so a relabelled seal is undecryptable by the person it was encrypted for. The label is bound to the key rather than asserted alongside it, and the outcome is a message the recipient genuinely cannot read. verify() catches the narrower case of a seal altered after signing in a way that survives decryption. An honest list of what has no automated test and why -- the recipient validation and the outbound id lookup (both need a database), the two transcript renderings (no Compose UI test dependency in this project), and anything touching a real MlsGroup. Better written down than rediscovered by someone assuming a green suite means the path is covered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a74a4b71cf |
test: cover the two decisions that decide who said what
The crypto was tested; the logic that acts on it was not. Both untested pieces were the security-critical ones, and neither fails loudly when it goes wrong -- one silently widens who may impersonate whom, the other silently destroys a message. Extracted MarmotDirectMessage.classify, which decides what an arriving wrap is to this device, from ChatMessage.directMessage, which turns that decision into rows. The decision is pure; only the filing needs a database, and Room-backed code cannot be unit-tested in this project. Same split, and for the same reason, as pulling the wrap/open crypto out of the DAO in the first place. Extracted MarmotInboundManager.mip03Rejection for the same reason. Its kind:1059 exemption is the most dangerous line in this feature: widened to another kind, or stripped of its kind guard, it hands every member of every group the ability to publish events as anybody, and nothing else in the pipeline would notice. There is now a test that walks seven kinds and asserts each is still held to MIP-03. Fifteen cases, the ones worth naming: `our own message is ours, even though we cannot open it` and `ours is decided before anything is opened`. A sender cannot decrypt their own wrap -- the key was discarded -- so by decryption alone this is indistinguishable from a bystander's view, and only the MLS identity separates them. Get it wrong and the inbound path files an empty placeholder over the row sendChatMessage wrote, which holds the only copy of those words. It is the one failure here that loses data rather than rendering something wrong. `words sealed by one member and sent by another are dropped`. The check that replaces MIP-03 for this kind, tested directly rather than described in a comment as it was before. One test asserts something I had wrong. I expected a seal relabelled with another member's pubkey to be caught by the signature check; it never reaches it. NIP-44 derives the conversation key from the pubkey being claimed, so relabelling a seal makes it undecryptable by the person it was encrypted for -- the label is bound to the key, not merely asserted alongside it. The outcome is Unreadable, which is the truth: the recipient genuinely cannot read it. `a seal tampered with after signing is dropped` covers what verify() does catch, using an alteration that survives decryption. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |