Commit Graph

469 Commits

Author SHA1 Message Date
Kgothatso Ngako
b200916844 feat: show the group's key in full, with a copy button
The shared-key screen showed `thresholdPublicKey.take(16)` followed by an
ellipsis. A 16-character prefix is enough to recognise a key you already know and
not enough for the one thing this key is for.

Members compare it out of band to confirm every device finished the ceremony on
the same key. That is the check that catches a device which quietly ended up
elsewhere -- and it cannot be done against a prefix, or from a screen the value
cannot be copied off. Both halves of that were missing.

The whole 66-character key now renders, wrapping rather than ellipsised, in a
monospaced face so a character-by-character comparison lines up instead of drifting
under proportional spacing. A FilledIconButton beside it copies the key via
LocalClipboardManager, the same way ShareProfileScreen and the image viewers
already do it. The "Key: " prefix became a label above so the key gets the full
width.

No copied-confirmation toast, matching ShareProfileScreen: Android shows its own
clipboard notice on 13+, and a snackbar here would double up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 13:45:34 +02:00
Kgothatso Ngako
cae50ce359 feat: hold the ritual until its owner approves each step
The ChillDKG ritual ran entirely on its own. `acceptProposal` published this
device's host key the moment a PROPOSAL arrived from a relay, and `advance`
published rounds 1 and 2 as soon as their inputs landed. Receiving a nostr event
was therefore enough to enrol the owner of a phone in a group's permanent signing
quorum, without anything having been shown to them first.

Nothing of this device's own now goes out before its owner says so. Three
approvals, because each publishes something different and commits the member to
something different:

  host key  joins the ceremony, and fixes n. A member who joins and then stops
            answering does not merely fail to help -- the ritual cannot finish
            without every member, so they hold it open for everybody.
  round 1   contributes to the key itself. The member's own secret material
            starts shaping a key they will be expected to help sign with.
  round 2   confirms the coordinator's combined result matches what this device
            sent. A check rather than a formality: it is what stops a coordinator
            substituting a key the members never contributed to.

The coordinator's two aggregations are deliberately not gated. They relay other
members' already-published messages and disclose nothing of the coordinator's own,
so an approval there would stall the whole group on one person's attention without
protecting anybody. The member who opens a ceremony is auto-approved for the host
key alone -- starting one is already the act of agreeing to be in it -- and is
still asked for rounds 1 and 2, which publish key material.

Each gate returns rather than throwing. The ritual is not failing, it is waiting
on a person; everything already received stays stored, so it resumes the moment
they approve. `pendingApproval` mirrors those gates exactly and has to keep doing
so: if the two disagree the screen offers an approval that does nothing, or none
while the ritual sits still.

Schema v2 -> v3 adds four nullable columns to DkgSession -- three approval
timestamps and `approvalRequestedThrough` -- so Room generates the migration. A
ritual already in flight comes back with all three null, which reads as "not
approved yet" and simply asks, rather than silently continuing.

## Being asked

Three screens rather than one parameterised by step, because each is making a
different case and the copy is the substance of the screen, not decoration around
it. They share a scaffold for one reason that is not cosmetic: a screen opened for
one step can go stale -- a redelivery carries the ritual forward, or the member
approves on another device -- so it re-checks the pending step before offering a
button, and `approve` checks again in the manager and ignores a mismatch.

"Not now" does not refuse on the member's behalf. There is no "no" in ChillDKG
short of abandoning the ceremony, and quietly leaving is what a member who is not
ready actually wants; abandoning stays on the ritual screen where the consequence
can be spelled out.

A chat line announces each request, written once per step and guarded by
`approvalRequestedThrough` -- `advance` runs on every arriving message and would
otherwise ask again on each one. It is the one ritual line that asks rather than
reports, so it is the one that is not quiet: primary tint, a Review affordance,
and a tap through to the ritual screen, whose bottom bar routes to the step the
ceremony is actually waiting on.

## Telling the steps apart

The request started as a single message type, which meant one icon for all three
and no way to tell "join the ceremony" from "confirm the key". The type is the
only thing a transcript keeps -- a line drawn days later has no session to ask
what was being requested -- so the step moved into it, one type per step, and
every stage now carries its own icon.

MIGRATION_3_4 rewrites the rows already written. They cannot regenerate: a request
is announced once, so a ceremony already in flight would keep its undifferentiated
icons forever. It changes no schema at all -- the version bump exists only to give
a data rewrite somewhere to run, which is why it is a manual migration on the
builder rather than another AutoMigration. Rows it cannot match keep the old type,
which the renderer still recognises.

An answered request shows a checkmark where Review was. Whether it was answered
comes from the transcript rather than the session: approving is the only thing
that causes the step to be published, and publishing writes an authored line, so a
matching line at or after the request means done. That keeps a room that has run
more than one ceremony correct -- ChatMessage has no session id to disambiguate
with -- and needs no DkgRepository in the message list. The comparison is on
createdAt rather than list position, because the list is ORDER BY createdAt DESC
with reverseLayout, where index arithmetic runs backwards.

Compiles and assembles; the ordering test still passes. No ritual has been run on
a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 13:43:46 +02:00
Kgothatso Ngako
6a5b6cd6cb Add ephemeral Relays.kt 2026-09-05 13:41:00 +02:00
Kgothatso Ngako
d9d27cd0ea fix: stop one bad request or one silent relay from stalling all synchronization
Two ways the sync queues could stop draining and never recover.

## A request that throws while loading the local set is never retried, and blocks
## every request behind it

The pending queue is a single row at a time:

    SELECT * FROM NegentropySynchronizeRequest WHERE status = 'pending'
    ORDER BY createdAt ASC, id ASC LIMIT 1

observed through distinctUntilChanged. The pump advances only when the head row
changes status, and the negentropy request was marked "sent" AFTER the storage
vector was built. StorageVector can throw on the way in -- insert() requires
exactly 64 hex characters, and seal() rejects a duplicate (timestamp, id) with
"duplicate item inserted". guardPump caught the throw and logged it, which kept
the pump alive but left the row at "pending". Nothing else observes that status,
the flow will not re-emit an unchanged row, so the request was neither retried
nor skipped: it sat at the head of the queue and every negentropy request queued
after it waited behind it for the life of the process.

The vector build now filters and de-duplicates on the way in -- a row negentropy
cannot index is one this device cannot reconcile, and dropping it costs one
event's worth of extra transfer where letting it through costs the entire sync --
and the request is claimed either way, so a failure that does get through logs
and lets the queue move on.

## A relay that opens a subscription and then goes quiet parks a slot forever

Both pumps take a permit from subscriptionSlots (4 across all relays) and hold it
for the life of the collection. The collection ends on EOSE, CLOSED or NEG-ERR --
none of which a relay is obliged to send. A negentropy exchange in particular
ends when reconcile() says so; if the relay simply stops answering mid-round,
nothing completes the flow. Four such subscriptions hold every permit and the
queue stops, with no error anywhere: the requests are marked "sent", so the UI's
pending count reads zero while nothing is being fetched.

Both are now bounded by SUBSCRIPTION_TIMEOUT (120s), which covers the collection
itself. The REQ pump is included because it is how negentropy's needIds are
actually fetched -- a wedged REQ slot breaks negentropy sync just as directly as a
wedged NEG one. Generous rather than tight: cutting a slow but live download short
costs a re-fetch next pass, and a REQ can now carry up to 500 ids. The existing
NEG-CLOSE/CLOSE in the finally block already runs under NonCancellable, so a
timed-out subscription still says goodbye to the relay.

Not covered by tests: both failures are timing and Room behaviour on the sync
path, neither of which runs under :composeApp:testDebugUnitTest. Verified by
compilation and by reading the queue's DAO query against the pump's collection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 11:27:44 +02:00
Kgothatso Ngako
2d29fc0a37 fix: reconcile with a relay to completion instead of stopping after one round
Negentropy is a multi-round protocol. The initiator opens with fingerprints over
its whole set -- 16 buckets, per kmp-negentropy's BUCKETS_IN_MESSAGE -- and the
peer answers each bucket either by agreeing (a skip), by listing the ids in that
range, or, when the range still holds more than 32 items on its side, by
splitting it into 16 finer fingerprints. Only the ranges that come back as id
lists produce have/need ids. Everything still under a fingerprint needs another
NEG-MSG from us, and reconcile() says so by returning a non-null `msg`; it
returns null exactly when there is nothing left to ask about.

This client discarded result.msg and never sent a second NEG-MSG. Worse,
isTerminalFor() listed NegentropyMessage as terminal, so completeOnSubscriptionEnd
ended the flow on the FIRST one -- the collector finished, the finally block sent
NEG-CLOSE, and a reconciliation the relay was still in the middle of was
abandoned. With 16 buckets a single round tells you almost nothing about a set of
any size: for anything past a couple of dozen events the exchange was torn down
before it had located most of the difference, and the ids it did find were
whichever handful happened to resolve at depth one.

The old comment on isTerminalFor described this as a deliberate design ("this
client reconciles in a single round"), which is what kept it in place. It is not
a design one can choose -- the protocol has no single-round mode. What it
produced was a sync that mostly did not sync, hidden behind a diff that was never
empty and a REQ fallback that quietly did the real work.

## The loop

NegentropyMessage is no longer terminal. The collector feeds each NEG-MSG to
reconcile(), accumulates the round's needIds/sendIds, and while `msg` is non-null
sends it straight back on the same subscription via the new
RelayPool.sendNegentropyMessage. When reconcile() returns null the exchange is
over -- a fact only the caller can see, since a relay owes us no EOSE for a NEG
session -- so a `transformWhile` on the flow ends the collection there. The
predicate reads a flag the collector sets, which works because a flow's
downstream collector runs synchronously inside emit().

MAX_NEGENTROPY_ROUNDS caps the ping-pong at 32 in case a peer's ranges never
converge; a healthy exchange settles in far fewer, since each round splits the
disagreeing ranges 16 ways.

## Acting once, at the end

Follow-ups moved out of the per-message branch into applyReconciliation, called
after the exchange. Acting per round would have queued a REQ for ids that later
rounds were still discovering. It runs outside the try and under NonCancellable
so an exchange that is cut short still acts on what it did reconcile rather than
discarding the rounds it paid for.

Two fixes came with the move:

  - needIds go out chunked at 500 per REQ. Relays cap the length of a filter's
    `ids` array (1000 is common) and a first sync can reconcile thousands; a
    single oversized REQ is answered with a CLOSED, or silently truncated, which
    loses every id past the cap. Previously all of them went in one filter --
    survivable only because one round never found many.
  - the "do we actually hold this?" check on sendIds is a Set lookup instead of
    `in` on a List, which was a linear scan per id over the whole local set.

Also dropped two logger.d calls that dumped every local event id and every local
timestamp on each NEG-MSG. At one line per message that was tolerable; at one per
round over a real set it is megabytes of logging on the hot path.

Not covered by tests: this is websocket exchange behaviour with a live relay.
Verified by compilation and by tracing kmp-negentropy's Negentropy.reconcile
against quartz's own NegentropySession, whose documented usage is the same loop
("If processMessage returns a non-null NegMsgCmd, send it back / repeat until a
result with a null command").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 11:27:04 +02:00
Kgothatso Ngako
661a5caa17 fix: build the local negentropy set from the whole filter, not a guess at its shape
A negentropy exchange compares two sets defined by the SAME filter: the relay
builds its side from the filter carried in NEG-OPEN, and this device builds its
side from getNegentropicNostrFeedIds. Any clause we fail to apply locally makes
our set a superset of the relay's, and each extra row comes back as an id the
relay is "missing" -- which this app then queues as a broadcast. Any clause we
apply more tightly makes it a subset, and the difference comes back as ids to
re-download that we already hold. Neither shows up as an error; both show up as a
sync that never settles.

getNegentropicNostrFeedIds was a `when` over the shape of the filter, dispatching
to one of eight hand-written @Query methods. Each method could only bind the
parameters it happened to declare, so the branches disagreed with the filter they
were serving:

  - `until` was expressible by NO branch. It is sent to the relay in NEG-OPEN and
    was never applied here, so every local event past the requested window was
    reported to the relay as one it lacked.
  - `since` was strict (`createdAt > :since`) where NIP-01 is inclusive, so an
    event stamped exactly on the boundary was a phantom "need" on every pass.
  - `kinds && authors` was tested before any tag branch, so a filter carrying
    kinds, authors AND tags silently dropped the tags. `kinds && ids` dropped
    authors. Every branch dropped whatever it had no parameter for.
  - tags were matched with `tags LIKE '%' || :value || '%'` -- a substring scan of
    the serialized tag JSON that matches the value in ANY tag position. A pubkey
    referenced in an `e` tag counted as a `p` match. And only `tags[name].first()`
    was ever bound, so the second and later values of a tag were dropped.
  - the reply branch matched `'%' || :eventId || '%reply%'`, which needs the
    literal text "reply" to appear somewhere after the id: it misses
    `["e","<id>"]` with no marker and false-positives on any later tag containing
    the word.
  - the `else` branch ignored the filter's kinds entirely and substituted
    `arrayOf(TextNoteEvent.KIND)`. A filter with only authors, or only tags, got a
    local set of kind-1 notes -- unrelated to what the relay was reconciling.
  - more than one filter returned emptyList() with a "not yet supported" warning.
    That is the worst available answer: an empty local set tells the relay we hold
    none of these events, so it hands back its entire set as ids to download.
  - the limit branches ordered `createdAt ASC LIMIT n`, returning the OLDEST n
    where a relay answering a limited filter returns the newest.

## The replacement

NostrEventFilterQuery translates a SynchronizationFilter into one SQL statement
that applies every clause, and NostrEventDao.getNostrEventsMatchingFilter runs it
as a @RawQuery. Raw because a nostr filter is a variable set of constraints over
variable-length lists, which is precisely what @Query cannot express -- and what
drove the per-shape methods that dropped constraints in the first place.

Semantics follow quartz's FilterMatcher, which is what the relays this app talks
to implement: membership for ids/authors/kinds; AND between tag names and OR
between the values of one name for `tags`; AND both ways for `tagsAll`; inclusive
`since`/`until`; and a present-but-empty list matches nothing.

Tags are matched by looking for the `["<name>","<value>"` fragment, built by
encoding through the same serializer that wrote the column so escaping agrees,
with `%`/`_`/`\` escaped and `ESCAPE '\'` on the LIKE so a wildcard inside a value
cannot widen the match. Anchoring on the tag name and on the closing quote of the
value is what keeps a hex string from matching in an unrelated tag position.

Multiple filters are now the union of their matches, de-duplicated by id.

## The Marmot branch is kept, and narrowed

Group messages still answer from MarmotGroupEvent: that table carries the NIP-40
expiry a relay uses to decide whether it still serves an event, and an indexed
chatRoomId instead of a scan of the tags JSON. But the branch now only claims a
filter it can fully honour -- exactly kind 445, an `h` tag, and nothing else --
because it answers from a different table and would otherwise reproduce the same
silently-dropped-constraint bug it is an exception to. It also fills in the `h`
tag and the real signature on the NostrEvent it synthesizes rather than leaving
them empty.

## Tests

NostrEventFilterQueryTest pins the generated SQL and the bound values for each
clause, including tag escaping and the empty-list case. It asserts the
translation rather than eyeballing it, because a dropped clause is not an error
at runtime -- it is reconciliation quietly reporting differences that are not
real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 11:25:15 +02:00
Kgothatso Ngako
d8729c5bff fix: sync group chat against live messages, not expired ones
getMarmotGroupEvents is the local half of a negentropy exchange for the
mlsMessages purpose: it answers "which kind-445 events for these rooms does this
device already hold", and the answer is compared against the same question asked
of the relay. Its expiry predicate read

    (expiresAt IS NULL OR expiresAt < :expiresAt)

with :expiresAt bound to Clock.System.now(). That keeps a row whose expiry is in
the PAST and drops every row still within its lifetime -- the exact inverse of
what a relay serves. NIP-40 says an expiring event is one a relay should stop
returning once its expiration tag has passed, so for every group message with an
expiration the local set handed to negentropy was the complement of the relay's.

The consequence is not a silent no-op. Reconciliation reports the symmetric
difference, so an inverted set turns every live message into an id the relay
believes we are missing (re-downloaded on every pass) and every expired message
into an id we believe the relay is missing (queued for re-broadcast). Group chat
therefore paid full transfer cost on every sync while pushing dead events back at
the relay -- which is also why the bug was invisible: messages still arrived,
just via the diff rather than the fast path.

Flipped to `expiresAt > :now`, and the parameter renamed to `now` since it is the
clock, not a bound on the column.

## Time bounds

NIP-01 `since`/`until` are inclusive: `since <= created_at <= until`. The query
used a strict `createdAt > :since` and had no `until` at all, so an event stamped
exactly on the boundary was in the relay's set and not in ours, and everything
newer than a requested `until` stayed in ours after the relay had excluded it.
Both are now applied inclusively; the one call site passes
Instant.DISTANT_FUTURE when the filter carries no upper bound.

## Ordering

ORDER BY flipped to createdAt DESC. It is irrelevant when the caller asks for the
whole set (negentropy sorts into its own vector regardless), but the parameter is
a LIMIT: a relay answering a limited filter returns the NEWEST matching events,
and ascending order returned the oldest.

Not covered by tests: Room DAO behaviour needs a sqlite driver, which
:composeApp:testDebugUnitTest does not have. Verified by KSP codegen -- the
generated NostrEventDao_Impl carries the corrected predicate -- and compilation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 11:24:21 +02:00
Kgothatso Ngako
3576c00ce2 feat: put every ritual message in the group's chat, naming who sent it
61480dd gave the group three lines: a ceremony started, it finished, somebody
abandoned it. Between the first and the second the ritual was a black box. A
ceremony that fixes the group's signing quorum for good ran with nothing to watch,
and a stalled one -- the common case, since it finishes only once every member's
device has taken part -- gave no way to see which member it was waiting on.

Every protocol message now gets a line naming the member whose device sent it:

  30311 host key     ...joined the shared key ceremony, publishing the key their
                        device is identified by for the rest of it.
  30312 round 1      ...sent their contribution to the key. Every member's is
                        mixed in, so no single device ever holds the whole thing.
  30313 coord r1     ...combined everyone's contributions and sent the result back
                        to be checked.
  30314 round 2      ...checked the combined result and signed to confirm it
                        matches what they sent.
  30315 certificate  ...gathered everyone's confirmations into a certificate. A
                        device that has it can finish and keep its share of the key.

The wording says what the step accomplishes rather than what it is called. "sent
pmsg1" is not a useful thing to read in a group chat, and the protocol names are
already on the shared-key screen this line taps through to.

30310 and 30316 keep the announcers they have: both say more than the message
carries -- the quorum in one case, who walked away in the other -- so folding them
in here would have lost that. `complete` stays the one unauthored line, because a
finished ceremony has no actor: the group ends up with a key, nobody hands it to
them.

This changes nothing about the protocol and adds no traffic. It extends the
mechanism 61480dd established rather than adding one: the rows are written locally
from ritual messages this device already has, with no giftWrapPayloadId and no
event behind them, so they cannot disagree with the ritual they describe and no
new kind goes on the wire. Adding the five types to DKG_TYPES and
DKG_AUTHORED_TYPES was enough to get RitualNotice's system-line treatment and its
name prefix, which resolves from the joined profile and so follows a rename.

The one genuinely new problem is idempotency, and it is the reason for six hooks
rather than one. ChatMessage has no key to make a second insert a no-op -- its id
is autogenerated -- while ritual messages arrive repeatedly: relays redeliver, and
replayStoredMessages feeds the whole backlog through record() again on every
resume. DkgParticipantMessage absorbs both, being keyed on
(sessionId, participantPublicKey, kind); a chat row cannot. So each announce is
guarded by reading the row it is about to write over, the same read-before-write
fail() already documents:

  - record(), for the three per-member kinds, checks getMessage before the upsert
  - record(), for the two aggregates, reads the column before update()
  - publishOwn(), for this device's own messages, checks ownMessage
  - the two coordinator broadcast sites, already inside `== null` guards

publishOwn's check is deliberately redundant with its callers', which all check
before publishing. The chat row is the thing with no fallback, so the check that
matters sits next to the write. An echo of this device's own broadcast finds the
row already stored and stays silent.

Worth knowing before this meets a real group: it is 3n + 4 lines per ritual -- 19
for five members, 34 for ten -- and a ritual is a burst, not a trickle. They are
compact single lines, but they will dominate a transcript while a ceremony runs.
If that reads as noise, the cheap fix is collapsing consecutive ritual lines into
one expandable line in ChatMessageListViewModel; the types are distinct enough to
group on, so no data change would be needed. That judgement wants a real ritual
first, which is also the only thing that will exercise these paths -- they are the
same paths the protocol messages take, and none of it has run on a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 10:43:01 +02:00
Kgothatso Ngako
74c352ab35 fix: show NIP-17 messages that arrive, not just the ones you send
A NIP-17 room only ever displayed your own words. Sending worked end to end --
sendChatMessage queues the gift wrap and writes a local ChatMessage so you see
what you typed -- but nothing on the inbound side ever wrote a row for a message
that arrived. The kind-14 branch decrypted the payload, stored it, built the
chat room from its p-tags, updated the subject, queued profile and relay-list
syncs, and stopped. The feed is `SELECT * FROM ChatMessage WHERE chatRoomId = ?`,
so with no row written there was nothing to show.

Every write of a ChatMessage in the tree confirms it: the sender's own copy in
DatabaseChatRepository, three MLS outbound sites, ChatMessage.fromGroupEventResult
for inbound MLS group events, one commented out in the welcome branch, and the
ritual notices. Nothing for an inbound gift wrap. MLS rooms were never affected,
which is why this survived -- CONVENIENT groups render both directions.

persistInboundChatMessage files the message once the room is known to exist.

## Two arrivals are deliberately not filed

A message already filed. Relays redeliver and negentropy re-syncs the same gift
wraps, and the same wrap yields the same payload id every time, so a lookup on
giftWrapPayloadId makes a redelivery a no-op. It has to be checked rather than
relied on: ChatMessage.id is autogenerated, so a second insert is simply a second
line in the conversation.

Our own words coming back. sealGiftWrapPayload wraps a copy to every participant
of the room including the sender, so a message returns to the device that sent it
-- and that device already wrote the row on the way out. Left alone, every
message you sent would appear twice.

The two copies of your own message cannot be matched on the payload id, which is
the interesting part: the outbound row is keyed on EventHasher.hashId over the
rumor, while GiftWrapSeal.decryptGiftWrapPayload keys the inbound one on the
seal's id. Same message, two ids -- and since every recipient gets their own
seal, the same message has a different id on every device that receives it. So
the sender is matched instead, which costs multi-device: a second install of the
same identity will not pick up messages sent from the first. Keying the inbound
payload on the rumor it came from would fix both, and would make payload ids
agree across devices, but it changes identity for every gift-wrapped kind rather
than just this one and belongs in its own change.

## Timestamps come from the rumor

NIP-17 fuzzes the seal and the wrap by up to two days to frustrate correlation,
so ordering the feed by either would shuffle the conversation into nonsense. The
rumor keeps the real time and that is what the row records.

## Scope

Kind 14 only, which is the kind this app sends. A kind 15 file message from
another client still falls through to the "Unsupported event" log, as before.

Not covered by tests: this is Room writes on the inbound path, which does not run
under :composeApp:testDebugUnitTest. Verified by compilation and by tracing the
branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:59:07 +02:00
Kgothatso Ngako
3f4f05162d feat: say who opened the ceremony on the shared key screen
The screen showed the quorum, the ladder and now the roster, but never named the
member whose ceremony it was. Any member can open one and it settles the group's
signing quorum for good, so who opened this one belongs on the screen that
describes it — the same reason the chat notice names them.

  Alice started this ceremony.
  2 of 3 members will be needed to sign with this key.

It sits above the failure branch so it holds in every state. A ceremony that was
abandoned or has already produced a key is still worth attributing: a member
arriving at a finished ceremony they do not remember agreeing to should be able
to see whose it was, and DkgSession.coordinatorPublicKey is kept for the life of
the row either way.

## One naming rule, in one place

HexKey.memberName() resolves a member's display name from the profiles joined
onto the room, falling back to a shortened key. The roster switched to it, so the
opener line and the rows below it cannot disagree about what to call somebody,
and the chat notice's copy of the fallback went with it.

This replaces a second private SHORTENED_PUBLIC_KEY_LENGTH I had added to
ChatMessageListViewModel. A third still lives in SelectChatRoomTypeViewModel at a
different value (12) and is deliberately untouched: that is a different choice
about a different surface, not a duplicate of this one, and folding them together
is a call about that screen rather than about this feature.

## Still reads a raw key on one surface

The abandoned card shows DkgSession.failureReason verbatim, which for a ceremony
ended by another member begins "Abandoned by 1a2b3c4d:" -- a truncated key where
the chat line now shows a name. Naming them there needs the culprit stored beside
the reason rather than inside it, which is a column on DkgSession and a schema
version, so it is left as it is rather than parsed back out of the string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:52:35 +02:00
Kgothatso Ngako
3fad331969 feat: name every member on the shared key ceremony screen
The ladder said "2 of 3" and stopped there. That is the one thing a stalled
ceremony never needs explaining — you can see it is stuck. What the group has no
way to find out is *who* it is stuck on, and since a ChillDKG ritual cannot
finish until every member's device has taken part, knowing whose door to knock on
is the group's entire recourse.

A roster now sits under the ladder, one row per member, each showing how far they
have got:

  ✓  You      confirmed everyone's part
  ✓  Alice    committed their part
  ○  Bob      not here yet

The wording is deliberately about what a member has *done* rather than a rung
number, since the rungs are named for the group's progress ("Round one") and a
member's own state is a different question.

Each member is named by the furthest round they have published, because that is
the only thing this device knows about them for certain — there is no liveness
signal in ChillDKG, and a member who published a host key an hour ago and then
closed the app is indistinguishable from one still working.

## Members, not counts, in the state

DkgRitualUIState carried three Ints. It now carries the three sets of public keys
they were counting, with the counts derived, so the ladder keeps working
unchanged and the roster has something to name people from.

The roster is drawn from `ritualMembers`: the room's participants deduplicated,
plus anyone who has published a ritual message and is not among them. The union
matters because the two sources can disagree — `n` is fixed from the proposal's
p-tags while the room's rows are local and can drift — and somebody who has
actually taken part is in the ceremony whatever the room's rows say. Showing a
count of 3 above a list of 2 names would be the worst of both.

Members are sorted by public key rather than by progress, so a row does not jump
around under the reader's finger as messages arrive.

## Only while it is running

The roster is skipped once a ceremony is COMPLETE, where the key card says
everything, and it is never reached for a FAILED one, which returns early on the
abandoned card. A half-climbed ladder of names next to "the ceremony was
abandoned" is noise: the ritual is over and who got how far no longer changes
what anyone should do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:47:02 +02:00
Kgothatso Ngako
95db7c2f73 fix: name the member behind a shared key ceremony notice
The ritual notices landed without an author. Dropping the bubble was right --
"a shared key ceremony started" is not something the coordinator said -- but
dropping the actor with it threw away the part of the line that matters most.
Any member can open a ceremony, and it settles the group's signing quorum for
good, so *who* opened this one is exactly what the group needs to see. Same for
who abandoned one.

System lines carry the actor in the sentence rather than in a header, so the
content of the authored types is now a predicate to be read after a name:

  Alice  started a shared key ceremony. It will take 2 of 3 members to sign
         with the key, and it finishes once everyone has taken part.
  Bob    abandoned the shared key ceremony. No key was created, and it is safe
         to run it again.
  ✓      The group has a shared key. It takes 2 of 3 members to sign with it.

A finished ceremony keeps no author, which is why DKG_AUTHORED_TYPES is a subset
rather than all three: the group ends up with a key, nobody hands it to them.

## The actor is resolved at render time, not written into the content

The manager could look the name up when it writes the row, and it would be wrong
twice over: the name would be frozen against later renames, and a member first
seen through this very proposal is sitting on the "LOADING..." placeholder that
getOrCreateNip17ChatRoom just inserted for them -- so the line would read
"LOADING... started a shared key ceremony" forever. LocalChatMessage already
joins Profile on senderPublicKey, so the renderer resolves it live, colours it
with ProfileColor like the message bubbles do, and falls back to a short key
when there is no profile yet.

## senderPublicKey now holds who acted

It was the coordinator on all three notices, which was wrong for an abandoned
ceremony: the member who sent FAILURE is the one who ended it. fail() takes a
culprit -- the FAILURE sender, defaulting to this device for a fault raised
locally, which amounts to the same thing from the group's side since hitting one
makes this device broadcast FAILURE in turn. isUserMessage follows from it, so
the line reads "You" for your own actions.

The fault itself is deliberately no longer in the chat line. "Abandoned by
1a2b3c4d: ChillDKG round 2 failed: a participant is faulty (participant 2)" in
the middle of a sentence about who walked away reads badly, and the detail is
already on the ritual screen the notice taps through to. DkgSession.failureReason
keeps it verbatim, so that screen is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:44:53 +02:00
Kgothatso Ngako
61480dd8b7 feat: tell the group in chat when a shared key ceremony happens
A ChillDKG ritual was invisible to everyone it happened to. Kinds 30310-30316
are routed to ChillDkgRitualManager and never become ChatMessage rows, so a
member's device published a host key and joined a ceremony that fixes the
group's signing quorum for good, with nothing appearing anywhere they would
look. The only way to find out was to open the group's details and press Shared
Key on the off chance. Worse in the flow this is reached through: a group whose
first event is the ceremony now materialises as a room with no messages in it at
all and no explanation of why it appeared.

That matters more than it would for a chat feature, because a ritual cannot
finish until every member's device has taken part. The progress ladder on the
ritual screen exists to show that it is waiting on 2 of 3 -- but nothing told
member 3 they were the one being waited on.

Three milestones now land in the transcript: the ceremony starting, the group
getting a key, and the ceremony being abandoned (with the reason, which names
the culprit participant when ChillDKG identified one).

## Derived locally, not sent

Nothing new goes on the wire. Every member already receives the proposal, and
computes the completion and any failure for themselves, so each device writes
its own row from what it already has. That costs no traffic, needs no new event
kind, and -- the reason it is worth doing this way -- makes it impossible for the
transcript to disagree with the ritual it describes. A "ceremony started" message
that was itself sent could arrive without the proposal, or outlive a session that
never existed on that device.

The rows are written where the state changes: announceStarted() at both places a
DkgSession is created (proposeRitual for the member who opens it, acceptProposal
for everyone else), and announce() at the COMPLETE write and in fail().

They are written once by construction rather than by de-duplication, which is
worth spelling out because ChatMessage.id is autogenerated and a second insert
would simply be a second line. A session is created once, since acceptProposal
returns early when the row exists; advance() leaves a COMPLETE ritual alone; and
fail() now re-reads the session and returns if it is already FAILED. That last
one is also a fix in its own right -- a ritual can be failed from two directions,
a FAILURE message from a member and a fault raised locally, and while the second
write was previously harmless it would now have told the group twice.

## Rendered as a system line, not a bubble

ChatMessage.messageType already carries "message", "artifact", "pendingCommit"
and eleven others, so TYPE_DKG_STARTED / _COMPLETE / _FAILED join it with no
schema change. But the list renders every row as a bubble with the sender's name
and a delivery-status icon, and neither fits: "a shared key ceremony started" is
not something the coordinator said, and a row with no gift wrap behind it would
show the KeyOff "unsealed" icon as though it had failed to send.

RitualNotice renders them across the width instead -- icon, text, timestamp, no
author, no side, no delivery state -- and is tappable through to the ritual
screen, since the point of telling the group is to give them somewhere to go. It
branches out of the items() lambda with an early return so the existing bubble
layout is untouched.

The other informational types ("pendingCommit", "processedCommit",
"proposalStaged", "undecryptableOuterLayer") have the same problem and are
deliberately left alone: how MLS commit rows should read is a separate call from
making the key ceremony visible.

## Not covered by tests

The whole change is Room writes and Compose rendering, neither of which runs
under :composeApp:testDebugUnitTest -- there is no sqlite driver on the JVM test
classpath. Verified by compilation only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:35:52 +02:00
Kgothatso Ngako
90a28e9321 fix: let a group actually finish a ChillDKG ritual
Nine defects, one of them enough on its own to stop any ritual from ever getting
past the member who opened it. They are committed together because the fixes
interlock: the coordinator fix rewrites the same guard that derives the
participant count, and the restart-safety rewrite replaces the control flow the
rest of them live in.

## The proposal was dropped by everyone it was sent to

acceptProposal accepted a proposal only from `localChatRoom.chatRoom.userPublicKey`
or from `coordinatorOf(localChatRoom)` -- which returned that same column, so the
two arms of the test were one test. And that column is not the room's creator: it
is the logged-in user, for multi-account support ("Logged in user PublicKey...
should help us have multiple user support"), which is why NostrDao sets it to
`activeKeyPair.pubKey.toHex()` on every room it builds. So the guard read "the
sender must be me", every recipient logged "DKG proposal from non-coordinator"
and dropped it, and the ritual never left the coordinator's device.

The same wrong notion made DkgRitualViewModel.isCoordinator() true on every
device, so every member was shown "Start key ceremony" and could open a competing
ritual.

There is no creator to recover. NIP-17 records none, ChatRoom has no such column,
and initialGiftWrapPayloadId is whichever member's message happened to arrive
first -- not the creator. So the coordinator is now simply whoever opens the
ritual, which is what ChillDKG assumes anyway: the coordinator relays, cannot
learn a secret and cannot bias the key, so being it confers nothing worth
reserving. The UI follows: canStartRitual() replaces isCoordinator(), and the
"Waiting for the group's creator to start it" copy is gone with the notion.

## Devices could disagree on n, which is fatal to a DKG

participantCount came from each device's own `localChatRoom.localParticipants.size`.
ChillDKG has no session-params object to agree on out of band -- every step hashes
the host public keys and the threshold into the session identity -- so two devices
that count n differently do not get a weaker key, they get no key. Local
membership is exactly the thing that drifts between devices.

n now comes from the proposal itself: its p-tags plus its sender, the set every
receiver sees identically. proposeRitual builds the same set the same way
(`memberPublicKeys(room) + userPublicKey`), so both sides count alike even if the
room's own rows have drifted.

Participant rows are also deduplicated by pubkey. Participant is keyed on an
autogenerated id, so upsert can leave the same member in a room twice, which
inflated n and double-p-tagged that member.

## A device killed mid-round stalled the ritual for everyone

advance() branched on DkgSession.stage, and each branch wrote the next stage
before publishing the message that stage stands for. A device that died in
between came back with the stage already advanced, skipped the branch, and never
published its round message -- and a DKG needs every member, so the whole group
waits forever on one that will never speak again.

advance() now asks what is *stored* -- "is my round-1 message out yet?" -- and
takes every step the stored messages allow, in order, stopping at the first one
still waiting on somebody. The stage is demoted to a label for the UI and only
ever moves forward (DkgRitualStage now documents that its declaration order is
the ladder, since the manager compares ordinals). publishOwn broadcasts before it
records, so a crash between the two costs a duplicate broadcast -- which every
receiver folds away on a keyed upsert -- rather than a message the group waits on
forever. The recursion is gone with the stage branching, and participantStep1's
result is reused instead of being recomputed for participantStep2.

## Messages that beat their proposal were thrown away

Gift wraps carry a randomised created_at (TimeUtils.randomWithTwoDays) and relays
hand them back in no particular order, so a round-1 message routinely lands
before the proposal that opens the ritual. Those arrived with no session to file
them under and were dropped, and nothing ever asked for them again.

They were never actually lost: the inbound path upserts every payload it decrypts
before it dispatches on kind. replayStoredMessages reads them back through the
new GiftWrapPayloadDao.getByChatRoomAndKinds as soon as the proposal creates the
session. No schema change -- the payloads were already there.

## Stale copies of the session clobbered each other

Handlers held a DkgSession across several writes and each `copy`d from its own
snapshot, so a later write silently reverted an earlier one -- aggregateRound1
storing cmsg1, then the failure path copying from a session fetched before it.
Every session write now goes through update(), which re-reads the row first, and
fail() with it, so a failed ritual keeps the progress it actually made.

## t = 1 was accepted from the wire

The threshold was taken straight off the proposal's tag. ChillDKG will happily
generate a 1-of-n "threshold" key that any single member can sign with, so the
check has to be ours: a proposal is now refused unless its threshold sits in
ChatRoomType.quorumRange(n), on the proposing side as well as the receiving one.
That also rules out t > n, which would otherwise throw inside ParticipantState1
and fail the session with a local input error.

## Anyone could pass themselves off as the coordinator

cmsg1 and the certificate were stored from whoever sent them. Both come from the
coordinator and only ever once, so a member could stall a ritual by getting a
bogus one in first: the real one would then be ignored as already-set. Both are
now accepted only from session.coordinatorPublicKey.

## Sorting host keys was not case-safe

The participant order is a sort of the host public keys, and it is the *order*
that has to match on every device, not the bytes. Hex from another client could
arrive upper case, parse fine, and sort into a different position -- silently
reordering the participant set and failing the session with no clue why. Both
sort sites now case-fold first.

## Cancelling the sync abandoned the ritual, and told the group to

advance() caught Throwable, which includes CancellationException, so tearing down
a coroutine scope marked the session FAILED and broadcast FAILURE to everyone.
NostrDao already rethrows cancellation for this reason; advance() now does too.

## Also

hostPublicKeys() and orderedPayloads() gated on `size < n` and then used whatever
they had; more host keys than the ritual was opened for now fails loudly instead
of running ChillDKG on a participant set nobody else has. quorumRange() no longer
returns a backwards, empty range for a room below the minimum, which coerceIn
rejects outright, and the ceremony is not offered at all below two members.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:13:46 +02:00
Kgothatso Ngako
739314ccb4 fix: stand up the chat room a ChillDKG proposal arrives for
Creating a NIP-17 group sends nothing to anybody. Membership under NIP-17 *is*
the p-tag set on each message, so the group only materialises on the other
members' devices when the first gift wrap lands. The chat-message branch of the
inbound path knows this and builds the room from the arriving payload's p-tags;
the ChillDKG branch did not. It looked the room up, found nothing, logged "DKG
payload for unknown chat room" and dropped the message.

That is fatal for the flow the feature is reached through.
SelectChatRoomTypeViewModel.createNip17ChatRoom writes the room and its
participants locally and navigates straight into the chat without publishing
anything, and ChatRoomDetailScreen offers "Shared Key" for exactly these rooms
(mlsGroupState == null). So "create group -> Shared Key -> Start key ceremony"
makes the ritual's own proposal the first event the group is ever heard of, and
every recipient dropped it. Nobody joined, and the coordinator sat on one host
key -- its own -- forever.

getOrCreateNip17ChatRoom builds the room the way the chat branch does: the
payload's p-tags plus its sender. That set is the aggregate ChatRoom.id is
derived from in the first place, so any payload that routes here already carries
the whole membership and there is nothing else to wait for. Placeholder profiles
are inserted first because both ChatRoom.userPublicKey and
Participant.participantPublicKey are foreign keys onto Profile, and a payload
whose membership does not include this device is refused rather than used to
build a room we are not a member of.

Deliberately not shared with the chat branch: that block also queues relay-list
and profile synchronisation per participant, which is best-effort enrichment
tangled into the surrounding loop's profilePublicKeysToSync map. Lifting it out
is worth doing on its own, not inside a fix whose job is to make the ceremony
reachable at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:12:58 +02:00
Kgothatso Ngako
c9b6bd7992 feat: run the ritual on bitcoin-kmp's native ChillDKG
Replaces `ac.cord.auxiliary.frost.dkg.chill.ChillDkg` with
`fr.acinq.bitcoin.crypto.dkg.chill.ChillDKG` throughout ChillDkgRitualManager.

The implementation being dropped says what it is in its own header:

    WARNING: This code is slow and not hardened against side channel attacks. Do
    not use for anything but tests.

It is a 1090-line "Reference port of ChillDKG (chilldkg_ref/chilldkg.py)" doing
BigInteger arithmetic through ac.cord.auxiliary.cryptography's Scalar and
GroupElement, with no call into libsecp256k1 anywhere in the file.
secp256k1-frost-kmp's own KNOWN_ISSUES.md confirms the constant-time
libsecp256k1 backing is used only for hashing. Real key material was being
generated by it.

The replacement is 438 lines in which every operation delegates to
`Secp256k1.chilldkg*` -- the constant-time C from the secp256k1-zkp fork the
submodule chain compiles. This is an improvement, not a clean bill of health: that
module carries its own "experimental and must not be used in production" warning.
It is constant-time C instead of variable-time Kotlin, which is the part that
mattered.

Three things changed shape rather than just types.

SessionParams no longer exists. ChillDKG takes the host public keys and the
threshold at every call and hashes them into the session identity itself, so
`sessionParams()` becomes `hostPublicKeys()` returning List<PublicKey>, and the
threshold rides along on the session row. That removed a parameter from four
signatures.

Faults are values now, not exceptions. ChillDKG reports a faulty participant as a
ChilldkgFault field on each result because it is a normal outcome of a DKG rather
than a bug. This ritual has exactly one response to all of them -- the key is
unusable, so the session dies and the group is told -- so `raiseIfFaulty` turns
them into an exception and lets them join advance()'s existing single failure
path. The gain is in the failure text: the reason shown to the group goes from
whatever `e.message` happened to hold to "ChillDKG round 2 failed: a participant
is faulty (participant 3)". On a failed DKG, which participant to blame is the
only actionable thing there is.

Two recomputes got names. The old code inlined a second participantStep1 call to
rebuild state1 for participantStep2, and rebuilt coordinator state separately in
aggregateRound2. Those are now `participantState1()` and `coordinatorStep1()`, the
latter carrying the fault check so both of its callers get it. The
recompute-rather-than-store design is kept deliberately: ChillDKG's states are
serializable and could be persisted, but they are pure functions of inputs the
DkgSession row already holds, so storing them would mean a schema change and a
Room migration for no behavioural gain. That reasoning is now in the kdoc.

Also drops an unused hostSeckey parameter from aggregateRound2, whose signature
was changing anyway, and updates doc comments in DkgRitualEvents, DkgSession and
DkgThresholdTag that named types which no longer exist -- ChillDkg.ParticipantMsg1
and friends -- to the protocol's own names: pmsg1, cmsg1, CertEq signature,
certificate.

Compiles clean, but no ritual has been run on a device. The natives are in the APK
as of the previous commit; the first real ritual is the actual test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:25:46 +02:00
Kgothatso Ngako
b5158e4e1e fix: drop the invalid fr.acinq.bitcoin.crypto package import
`fr.acinq.bitcoin.crypto` is a package, not a declaration -- it is where bitcoin-kmp keeps
Digest, Pack and hmac. Kotlin has no import-a-package form, so this line was never valid:

    e: ChillDkgRitualManager.kt:21:25 Packages cannot be imported.

It was also unused. Nothing in the file references anything under that package; the crypto
the file actually uses is fr.acinq.bitcoin.Crypto on the line above, plus quartz's EventHasher.

Worth recording why this only surfaced now, since the line has been there since 3995cde and
every build since has been green. Kotlin's incremental compiler had not revisited this file:
it was last compiled when the classpath still resolved lightning-kmp and bitcoin-kmp from maven
central, and the switch to building them from the experimental submodule chain did not
invalidate its entry. Changing AGP invalidated the incremental caches, forced a full recompile,
and the compiler read the file for the first time in a while.

So this is not fallout from either the submodule update or the AGP bump. It is a latent error
that any clean build would have hit -- including CI, or the first build on a fresh clone.

The import is left in place commented out rather than deleted, as a marker of where the
package's contents were expected to be needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:58:09 +02:00
Kgothatso Ngako
0d6cefbe21 fix: stop the sync pumps opening unbounded relay subscriptions
Relays were answering with "too many concurrent REQs" and the emulator
log was full of it. Two independent defects, compounding.

## The subscription flow never completed

RelayPool.queryAsFlow returned a filtered view of the socket's hot
`incomingMessages`, with the terminating operator commented out:

    return this.incomingMessages
        .filterBySubscriptionId(id = subscriptionId)
    //  .transformWhileEventsAreIncoming()

A filter over a hot flow has no terminal event, so every collector
started for a sync request stayed alive for the life of the app --
accumulating one per request ever made, long after EOSE and CLOSE had
been sent. Neither pump's EOSE branch ended collection either;
`return@collect` only ends handling of the message in hand, as the
CLOSED branch's own comment already noted.

That is also why the log *flooded* rather than merely warning.
`filterBySubscriptionId` admits NoticeMessage on every subscription id
(a NOTICE carries none), so a single "too many concurrent REQs" notice
was delivered to every accumulated collector and logged once per
collector. Volume grew as notices x live collectors.

## Nothing bounded how many were open

Both pumps mark the request "sent" and then launch the subscription
detached:

    nostrRepository.negentropySynchronizeRequestProcessed(request)
    launch(Dispatchers.IO) { relaysSocketManager.negentropySync(...).collect { ... } }

The DAO query is `WHERE status = :status ... LIMIT 1`, so flipping the
row changes the head row, Room re-emits, and the collector body runs for
the next request while the previous subscription is still open. The
mutex covers only the setup block and publishSlots guards publishes, not
REQs, so the number of simultaneously open REQ/NEG-OPEN subscriptions
was bounded only by backlog depth.

## And back-pressure amplified itself

The negentropy ClosedMessage branch -- CLOSED being exactly what a relay
sends when refusing for too many concurrent REQs -- answered by queuing
the request again as a plain REQ. Each refusal therefore produced
another subscription. That branch also skipped the close its EOSE and
NEG-MSG siblings performed, leaking a slot precisely when the slot was
most needed.

## The fix, in the order it has to be applied

1. sockets/NostrIncomingMessageExt.kt gains isTerminalFor() and
   completeOnSubscriptionEnd(), which emits the terminal message and
   then completes. NOTICE is deliberately not terminal: with no
   subscription id it reaches every collector on the socket, so treating
   it as terminal would tear down every unrelated subscription at once.
2. RelayPool.queryAsFlow applies it, replacing the commented-out call.
3. SynchronizationViewModel gains subscriptionSlots =
   Semaphore(MAX_CONCURRENT_SUBSCRIPTIONS = 4), acquired inside each
   pump's launch before the socket call, so the backlog still drains but
   queues on the semaphore rather than opening all at once.
4. Both pumps close in a `finally` under NonCancellable, replacing the
   hand-rolled closes in the EOSE and NEG-MSG branches, so CLOSED and
   NEG-ERR exits close too.
5. The negentropy CLOSED branch consults isBackPressure() -- NIP-01's
   `rate-limited:` prefix plus the free-text forms relays actually send
   -- and declines to retry, instead of answering back-pressure by
   opening another subscription.

Order is load-bearing: capping slots before the flow could complete
would have deadlocked the pump on permits that never came back.

## A negentropy assumption that would have deadlocked it anyway

Capping slots nearly stalled the queue on a wrong assumption about what
ends a negentropy exchange. EOSE does not: the NegentropyMessage branch
reconciles ONCE, queues the ids it needs as a plain REQ, schedules what
the relay is missing, and stops -- this client does single-round
reconciliation. Waiting on an EOSE the exchange need not send would have
held all four slots forever. NegentropyMessage is therefore terminal
too, with the reasoning recorded at isTerminalFor().

Worth knowing separately, and left alone here: single-round
reconciliation may not converge on large sets, since negentropy is
normally iterative. A large divergence is closed by the plain-REQ
fallback rather than by negentropy itself.

Live collectors go from one per sync request ever made to at most four.
MAX_CONCURRENT_SUBSCRIPTIONS is the dial if sync feels slow -- relays
commonly allow around 20 per connection, so there is headroom.

Verified:
  ./gradlew :composeApp:compileCommonMainKotlinMetadata
  ./gradlew :composeApp:compileDebugKotlinAndroid

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:04:06 +02:00
Kgothatso Ngako
e1b79ad842 Selection container on text 2026-08-29 19:39:40 +02:00
Kgothatso Ngako
3995cdefc6 feat: run a ChillDKG ritual over a NIP-17 group
Robust groups can now generate a FROST threshold key together. The
group's members are the participants, the room's creator is the
coordinator, and the whole protocol travels as gift-wrapped rumors on
the chat the group already has -- so there is no second transport to
build, operate or debug.

This is what the quorum has been reaching for since it was introduced.
Until now "t of n must approve" had no key to approve anything with;
ChillDKG produces one that no single member holds.

## Transport: seven rumor kinds (nostr/dkg/)

    coordinator --[ 30310 proposal      ]-> everyone
    participant --[ 30311 host key      ]-> everyone
    participant --[ 30312 round 1       ]-> everyone   ParticipantMsg1
    coordinator --[ 30313 coord round 1 ]-> everyone   CoordinatorMsg1
    participant --[ 30314 round 2       ]-> everyone   ParticipantMsg2
    coordinator --[ 30315 certificate   ]-> everyone   CoordinatorMsg2
    anyone      --[ 30316 failure       ]-> everyone   abort + reason

These only ever exist inside a NIP-17 gift wrap, so no relay sees them
unencrypted and the replaceable semantics normally implied by the 3xxxx
range never apply -- which is why they can sit next to the app's other
private kinds (30300-30309) without meaning anything different.

Every message is addressed to the whole group, even the two the protocol
only needs the coordinator to read. NIP-17 wraps per recipient anyway,
ChillDKG treats the coordinator as untrusted by construction, and having
every member observe the ritual is what makes a progress UI possible
without a side channel.

`DkgSessionIdTag` is on every message: a group may abandon an attempt and
start another, and a straggler from the dead one must be dropped rather
than mixed into the live session. `DkgThresholdTag` rides the proposal so
every participant validates the same SessionParams -- disagreement on `t`
fails the session instead of quietly producing a weaker key.

## Persistence: inputs, not state (database/model/Dkg*, schema v2)

DkgSession deliberately stores no protocol state. Reading EncPedPop
confirms randomness enters the participant steps only through the passed
`random`/`auxRand` arguments (`simplSeed = taggedHash("encpedpop seed",
seed + random + encContext)`), so every ChillDkg step is a pure function
of inputs. Keeping the two 32-byte randoms plus the received messages is
therefore enough to recompute any intermediate state on demand, and the
opaque ParticipantState/CoordinatorState objects -- which have no
serialization API -- never need to be persisted at all.

That is not a micro-optimisation. A DKG cannot finish unless all n
members take part, and chat users close apps mid-round; recomputation is
what lets a ritual resume instead of forcing the group to start over.

DkgParticipantMessage is keyed (sessionId, participantPublicKey, kind) so
a redelivered message overwrites rather than accumulates -- relays
redeliver, and a duplicated round-1 message would hand the coordinator a
participant list of the wrong length.

Database goes to version 2 with an AutoMigration: v2 only adds tables, so
Room generates it. Schema 2.json is exported alongside.

## Driving it (managers/ChillDkgRitualManager.kt)

State machine driven entirely by arriving messages: persist, then ask
whether the ritual can move. Because every step is recomputable there is
no long-lived session in memory to lose, and processing is idempotent --
a redelivered message re-runs a step that has already been taken and
changes nothing.

The coordinator is a participant too, so it records its own outbound
messages locally: its round-1 message has to be in its own aggregation
alongside everyone else's. Being the room's creator buys it no authority
here -- ChillDKG's coordinator relays but cannot learn secrets or bias
the key -- only the job of aggregating.

Two decisions worth knowing:

* Host keys are DERIVED, not reused. `sha256("mantra/chilldkg/host-key/v1"
  || nostrSeckey)`. Reusing the nostr identity key directly was the
  simpler option, but one secret serving two protocols means a flaw in
  either reaches the other. Deriving from the same seed keeps it
  recoverable from the wallet backup, which matters because ChillDKG
  needs the host secret key to recover a session's outputs and asking
  chat users to back up a second secret is how keys get lost.
* Participant order is a bytewise sort of the host public keys. ChillDKG
  fails outright if participants disagree on ordering, and a sort is the
  only order every device can derive independently from the same set.

Any ChillDkg exception ends the session for this device and is broadcast
as a 30316 so the rest of the group stops waiting, rather than leaving
every member on a spinner that will never resolve.

## Inbound (database/dao/NostrDao.kt)

One branch on the existing decrypted-gift-wrap dispatch, beside the
kind-14 and WelcomeEvent branches, handing ritual kinds to the manager.

## UI (ui/.../DkgRitualScreen.kt + view model, state, route)

Reached from chat room detail via "Shared Key", shown only for rooms with
no MLS state -- i.e. the NIP-17/robust ones. An MLS room has a single
admin and no group key to share, so the entry point would be a lie there.

The screen is a ladder of rounds with real counts ("3 of 5") rather than
a spinner. The unusual thing about a DKG, and the thing the UI has to get
across, is that it needs *everyone* at once; a count says who it is
waiting on, an indeterminate spinner says nothing. The coordinator gets
the start button, everyone else is told who they are waiting for, and a
failed ritual states plainly that no key was created and it is safe to
run again.

DkgSession.threshold finally gives the quorum somewhere to live. The
value chosen during group creation is still not persisted on ChatRoom,
so this screen re-asks with the same majority default rather than
inventing a different one; there is a TODO where that gap closes.

Verified:
  ./gradlew :composeApp:compileCommonMainKotlinMetadata
  ./gradlew :composeApp:compileDebugKotlinAndroid

Not runtime-verified: exercising a DKG needs several devices exchanging
live messages, and the library's own vector suite needs JDK 21.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:15:17 +02:00
Kgothatso Ngako
ea33217203 feat: build robust groups as NIP-17 instead of Marmot/MLS
Robust rooms are now plain NIP-17 group chats: no MLS group, no key
packages, no invites, no admin. Convenient rooms are unchanged and stay
Marmot/MLS.

Everything needed to *run* a NIP-17 group was already here; what was
missing was any way to start one.

* Inbound already worked for N participants, not just a pair.
  GiftWrapSeal derives the room id from the author plus every p-tag via
  ChatRoom.deriveChatRoomId (a musig2 aggregate over the member set),
  and NostrDao stands the room up with mlsGroupState = null and a
  Participant row per p-tag, inserting placeholder profiles and queueing
  a profile sync for anyone unknown.
* Outbound already worked too. sendChatMessage branches on
  mlsGroupState == null into gift wraps p-tagged to every participant,
  sealGiftWrapPayload wraps the payload once per participant, and
  NotaryViewModel drives that loop at runtime -- so the path is live,
  not merely present.
* The gap was creation. NostrNip17Dao.getOrCreateChatRoom only ever
  inserts the active user as a participant (the peer is literally
  commented out of its hexKeys set) and expects the room id to be handed
  to it, which suits an inbound message and nothing else.

database/dao/NostrNip17Dao.kt
* New createNip17ChatRoom(): derives the id with the SAME
  deriveChatRoomId the inbound path uses, so the creator and every
  recipient independently arrive at the same room, and building the same
  group twice is idempotent rather than duplicative. Then upserts the
  room with mlsGroupState = null -- which is precisely the flag
  sendChatMessage reads to choose gift wraps -- and a Participant row
  for the creator plus every picked member.

repository/ChatRepository.kt, database/repository/DatabaseChatRepository.kt
* Expose it, with the same try/catch-and-log-null shape its
  getOrCreateChatRoom sibling uses, plus the NO_OP stub for previews.

ui/view/model/SelectChatRoomTypeViewModel.kt
* createChatRoom() splits on the chosen type into createMarmotChatRoom()
  and createNip17ChatRoom(). The convenient path is the previous body
  verbatim. The robust path skips key package resolution, the 20-second
  relay budget and the whole sequential invite loop, because NIP-17
  membership IS the p-tag set -- there is nothing to invite anybody to,
  and so no partial-failure case either.
* Removes the .copy(adminPubkeys = ...) added in bf94ecb. Robust was the
  only thing that ever set a multi-admin list; with convenient now the
  only MLS path, MarmotGroupData.bootstrap() already stamps
  creator-only, so the override had become a branch that could not be
  taken.

Trade-offs this bakes in, recorded here and in a TODO on the new path:

* The quorum has LESS meaning under NIP-17, not more. There is no group
  state to change and so nothing to approve: membership is whatever a
  message is addressed to, and a different member set is a different
  musig aggregate, i.e. simply a different room. Under MLS there was at
  least an admin_pubkeys list to hang FROST off later; here there is no
  object for t-of-n to govern at all. The picker is still shown and
  still has nowhere to persist to.
* Members do not learn the room exists until the first message is sent.
  NIP-17 has no invite event -- the first gift wrap is the invitation.
* Robust rooms give up MLS forward secrecy and the sender ratchet. What
  they gain is that there is no privileged member and no shared group
  state to desync.

Verified:
  ./gradlew :composeApp:compileCommonMainKotlinMetadata
  ./gradlew :composeApp:compileDebugKotlinAndroid --rerun-tasks

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 18:39:43 +02:00
Kgothatso Ngako
65182ac892 refactor: route every MLS group restore through ChatRoom.toMlsGroup()
Four places restored an MlsGroup from a chat room's persisted state, each
spelling out the same MlsGroup.restore(MlsGroupState.decodeTls(hex))
chain by hand. Three of them could not have called the shared helper
even if they wanted to: until ea8b2b2, ChatRoom.toMlsGroup() demanded a
pastGenerations count so it could replay the sender ratchet, which is a
question none of these callers had any business answering. Dropping that
parameter left the helper callable everywhere, so call it everywhere.

database/repository/DatabaseChatRepository.kt
* sendChatMessage() restores via toMlsGroup(). It only wants to know
  whether the room is MLS-backed or gift-wrapped, which is exactly what
  a null return says.

ui/view/model/AddArtifactViewModel.kt
* Same collapse: the ?.let { restore(...) }?.let { mlsGroup -> ... }
  double-let becomes toMlsGroup()?.let { mlsGroup -> ... }.

database/dao/NostrDao.kt
* The fourth copy, and the one easiest to miss: it decoded the hex into
  a mlsGroupStateByteArray temp and then restored under an explicit null
  check on the bytes, rather than the ?.let the others used. Same
  operation wearing different clothes. Now toMlsGroup() with a null
  check on the group itself, which is what the branch actually meant.

None of these three encrypt, so none of them ever needed the generation
replay the old signature forced on them -- NostrDao is the inbound path,
AddArtifactViewModel only gates on the group existing, and
sendChatMessage writes a rumor for the outbound pipeline to encrypt
later. The only caller that does encrypt, encryptAndSendMarmotInnerEvent,
persists mlsGroup.saveState() immediately afterwards, so the ratchet
position now round-trips through storage on its own.

Unused imports go with them: MlsGroup and MlsGroupState from the first
two files, MlsGroupState from NostrDao (which still needs MlsGroup for
processWelcome).

MlsGroup.restore now appears exactly once in the codebase, inside
toMlsGroup() itself.

Verified:
  ./gradlew :composeApp:compileCommonMainKotlinMetadata
  ./gradlew :composeApp:compileDebugKotlinAndroid --rerun-tasks

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 18:38:39 +02:00
Kgothatso Ngako
ea8b2b276a Remove hack because quarz didn't persist the secretTree in an older version. 2026-08-29 16:57:26 +02:00
Kgothatso Ngako
9b3044f6fb feat: let robust groups choose their own quorum
Picking Robust now asks how many of the admins have to approve a change
instead of silently assuming a simple majority. The majority is still
where the answer starts; it is just no longer the only one available.

database/model/types/ChatRoomType.kt
* approvalThreshold(adminCount) becomes defaultQuorum(adminCount): same
  simple majority, but named for what it now is -- an opening position
  rather than the rule.
* MINIMUM_QUORUM = 2. One approval is not a quorum, it is one person
  acting alone, which is what CONVENIENT already offers.
* quorumRange(adminCount) = MINIMUM_QUORUM..adminCount -- never fewer
  than two, never more admins than exist to approve. Because ROBUST is
  gated at MINIMUM_ROBUST_GROUP_SIZE = 3, the range always holds at
  least two choices, so the picker is never a control with nothing to
  pick.

ui/view/model/SelectChatRoomTypeViewModel.kt
* The fixed approvalThreshold field becomes quorum: MutableState<Int>,
  seeded from defaultQuorum(adminCount), alongside the quorumRange the
  UI clamps against.
* setQuorum() coerces into quorumRange, so the value cannot escape the
  bounds even if the buttons' own enablement is wrong, and freezes once
  createdChatRoomId is set -- past creation the governance is already
  stamped into the epoch-0 group context, exactly as selectChatRoomType
  does.
* quorumExplanation() states what the choice costs day to day: "Any 3 of
  you can approve a change -- the other 2 don't have to be around", and
  at the top of the range "Every admin has to agree. If one of you goes
  quiet, nothing about the group can change." Unanimity is a real
  liveness risk and the user should read that before choosing it, not
  after.

ui/composable/SelectChatRoomTypeScreen.kt
* ChatRoomTypeCard gains a trailing content slot, and the robust card
  fills it with the new QuorumPicker -- but only while robust is the
  selected type. Before that there is no decision to make and the
  question would be noise.
* QuorumPicker is a stepper, not a text field: the range is small, both
  ends are bounded, and a stepper cannot produce a value that has to be
  rejected. The -/+ buttons disable at quorumRange.first/last and the
  caption re-reads on every step.
* The robust card's own prose drops the hard number -- "approved by a
  quorum of you" rather than "approved by 3 of the 5 admins" -- because
  the number is now a choice rather than a fact, and the picker is the
  thing that states it.

Not done, and called out in the TODO next to the admin list: the chosen
quorum is not persisted. It cannot ride in MarmotGroupData -- MIP-01's
wire format is fixed and an extra field would break byte-compatibility
with mdk/whitenoise -- so it needs a ChatRoom column and the Room
migration off schema version 1 that comes with it. Until then the quorum
is a stated intent sitting beside the admin list, in the same way the
t-of-n enforcement itself is still waiting on FROST signing over admin
changes.

Verified:
  ./gradlew :composeApp:compileCommonMainKotlinMetadata
  ./gradlew :composeApp:compileDebugKotlinAndroid

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 16:53:23 +02:00
Kgothatso Ngako
bf94ecbe76 feat: choose how a group is run before creating it
Add a third and final group-creation step, after the member picker, that
asks whether the new group should be convenient (the creator is its only
admin) or robust (every member is an admin and a change needs a
threshold of them to approve it).

The flow is now:
  ChatRoomCreationRoute          (name + description)
    -> SelectChatRoomMembersRoute    (pick people)
      -> SelectChatRoomTypeRoute     (how it is run, then build it)
        -> ChatRoomMessagingRoute

New: database/model/types/ChatRoomType.kt
* CONVENIENT / ROBUST, plus the two rules that go with them.
* approvalThreshold(adminCount) is a simple majority, so no half of the
  group can move without the other.
* MINIMUM_ROBUST_GROUP_SIZE = 3 and isRobustAvailable(memberCount).
  Below three a majority is not a meaningful check: at two admins every
  change needs both of them, and at one the creator is deciding alone,
  which is CONVENIENT under another name.

New: ui/composable/navigation/routes/SelectChatRoomTypeRoute.kt
* Carries activeUserPublicKey, name, description and the picked
  memberPublicKeys. The governance choice feeds the epoch-0 group
  context, so nothing can be persisted until it has been made and every
  earlier answer has to ride along to this step.
* memberPublicKeys is a List<String>. Navigation 2.9.2 resolves that
  through NavType.StringListType (NavTypeConverter maps
  InternalType.STRING inside a collection onto it), so it needs no
  hand-rolled encoding.

New: ui/view/state/SelectChatRoomTypeUIState.kt
* Loading/Loaded/Error, with Loaded carrying the picked members so the
  screen can name them rather than echo hex keys.

New: ui/view/model/SelectChatRoomTypeViewModel.kt
* Owns the choice (selectedChatRoomType, convenient by default because
  it is the option that always works) and the group creation and invite
  round, both moved here wholesale from SelectChatRoomMembersViewModel.
* The choice is not cosmetic: it decides adminPubkeys on the epoch-0
  MarmotGroupData. CONVENIENT stamps just the creator; ROBUST stamps the
  creator plus every picked member, deduplicated (MIP-01 rejects
  duplicates). MarmotInboundManager.processGroupMembershipChanges
  already derives Participant.adminAt from exactly this list, so admin
  status propagates to every member's device without further work.
* MarmotGroupData.bootstrap() still stamps the base metadata -- the
  admin list is layered on with copy() -- so UI and CLI stay
  byte-identical on everything else.
* selectChatRoomType() refuses ROBUST when the group is too small, and
  freezes once the room exists: by then the choice is baked into the
  epoch-0 group context and re-picking would change nothing.
* robustUnavailableReason spells out the shortfall ("Go back and add 1
  more person"), pluralised here rather than in the composable.
* Known gap, left as a TODO next to the admin list: robust rooms get the
  admin set but not the t-of-n approval itself. That needs FROST signing
  over admin changes -- the same thing the existing "generate GID
  through frost" TODO is waiting on. Until then every admin of a robust
  room can still commit on their own.

New: ui/composable/SelectChatRoomTypeScreen.kt
* Two radio cards. Convenient explains that the creator acts alone and
  that nobody can carry the group on without them; robust quotes the
  real numbers -- "approved by 2 of 3 admins" -- computed from the
  actual selection instead of leaving t-of-n abstract.
* Below MINIMUM_ROBUST_GROUP_SIZE the robust card renders disabled
  (Card(enabled = false), disabled RadioButton, no click) and shows the
  reason in the error colour. It stays on screen rather than vanishing,
  so the option is discoverable and the fix -- go back, tick one more
  person -- is obvious.
* Carries the bottom bar the members step used to own: the
  create/progress/"Open chat" action and the partial-invite warning,
  which now name members via displayNameFor() since this step only
  receives keys.

SelectChatRoomMembersViewModel / SelectChatRoomMembersScreen
* Reduced to what their names say. Group creation, the invite round, the
  key package lookup, the wallet flow and ChatRepository all move to the
  type step; what stays is listing local profiles, ticking them, and
  handing the keys on through SelectChatRoomTypeRoute.
* The up-front key package sync stays here, which is the point of doing
  it early: it now has the whole type-selection step to land in before
  anybody is invited.
* The action becomes "Next with N" and the empty-store copy no longer
  offers to create the chat, because this step no longer can.

MantraNavHost
* Register SelectChatRoomTypeRoute. Opening the finished chat still pops
  back through ChatRoomCreationRoute inclusive, so backing out of a new
  chat lands where the user started rather than part-way through the
  three creation steps.

Verified:
  ./gradlew :composeApp:compileCommonMainKotlinMetadata
  ./gradlew :composeApp:compileDebugKotlinAndroid

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 16:44:29 +02:00
Kgothatso Ngako
c5c4bc9ed6 feat: pick group members before creating a chat room
Group creation was a single screen: name + description, then "Create
chat" minted the MLS group and dropped the user straight into an empty
room, with no way to bring anyone along except the existing
one-at-a-time invite path (chat room detail -> search member ->
confirm). Add a second step that lists the profiles already in the
local store and lets the user tick everyone the group is for, so a
group is created together with its members.

The flow is now:
  ChatRoomCreationRoute      (name + description)
    -> SelectChatRoomMembersRoute  (pick people, create, invite)
      -> ChatRoomMessagingRoute

New: ui/composable/navigation/routes/SelectChatRoomMembersRoute.kt
* Carries activeUserPublicKey plus the name/description gathered by step
  one, so nothing is persisted until the user confirms who is in.
* `description` is nullable with a default, and ChatRoomCreationViewModel
  maps blank text onto null: an empty string is not something navigation
  round-trips reliably as a path argument.

New: ui/view/state/SelectChatRoomMembersUIState.kt
* The Loading/Loaded/Error triple the other chat screens use. Loaded
  carries the pickable profiles.

New: ui/view/model/SelectChatRoomMembersViewModel.kt
* initiate() lists nostrRepository.searchableProfiles() excluding the
  active user -- purely local rows, no directory lookup. It also queues a
  negentropy sync for every listed profile's KeyPackageEvent up front, so
  the packages needed to actually add anybody have usually landed by the
  time the user has finished ticking names.
* createChatRoom() moves here from ChatRoomCreationViewModel, unchanged
  in substance: bootstrap MarmotGroupData into the epoch-0 GroupContext,
  MlsGroup.create, then getOrCreateChatRoom keyed on the Marmot
  nostr_group_id (not MlsGroup's own groupId). Minting the group here
  rather than in step one is the point of the split -- backing out of the
  picker no longer strands a member-less room in the chat list.
* inviteSelectedMembers() resolves the selected members' key packages
  concurrently under one shared 20s budget (a single relay round trip for
  the batch instead of one timeout per member) by observing
  observeMarmotKeyPackageForPublicKey, then invites sequentially. The
  room is re-read from the repository before every invite: inviteMember
  advances the MLS epoch and persists the new state, so reusing the
  snapshot taken before the previous invite would build the next commit
  on top of state the group has already left.
* A member whose key package never shows up does not sink the group. It
  is created without them and the screen names who was left out;
  createdChatRoomId then turns the action into "Open chat" against the
  room that already exists rather than minting a second one, and
  toggleMember is frozen once the room exists so further ticks cannot
  look like they will still be honoured.

New: ui/composable/SelectChatRoomMembersScreen.kt
* Checkbox list over Loaded.profiles, reusing the row shape of
  SearchMemberToAddToChatRoomScreen (ProfileAvatar + name + about); both
  the row and the checkbox toggle selection.
* BottomAppBar carries the running selection count and an
  ExtendedFloatingActionButton labelled "Create chat with N", which
  swaps to a progress indicator while the group is being built.
* An empty local store gets an explanatory state that still allows
  creating the chat and inviting people later.

ChatRoomCreationViewModel
* Reduced to the details form. Group creation, the wallet keypair and
  both repositories move to the picker, so factory() now takes no
  arguments at all.
* Gains validateInput() (mirroring CreateProfileViewModel) so an unnamed
  chat cannot advance, and selectMembers() to hand the collected
  name/description to the next route.

ChatRoomCreationScreen
* Takes activeUserPublicKey from the route instead of the wallet flow
  and the two repositories; the button becomes "Choose who to chat
  with".
* Fix the "groupd" typo in the name placeholder.

MantraNavHost
* Register SelectChatRoomMembersRoute. Opening the finished chat pops
  back through ChatRoomCreationRoute inclusive, so backing out of a
  brand new chat lands where the user started rather than in the
  half-filled creation form.

Known limits: the picker inherits ProfileDao's default LIMIT 21, so only
the first 21 local profiles are offered (the same cap the existing
add-member search already lives with), and a selected profile can only
join if their KeyPackageEvent is reachable -- contacts who have never
published one always land in the "couldn't be added" list.

Verified:
  ./gradlew :composeApp:compileCommonMainKotlinMetadata
  ./gradlew :composeApp:compileDebugKotlinAndroid

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 16:14:37 +02:00
Kgothatso Ngako
086b2f4e70 Update dependencies 2026-08-15 15:42:34 +02:00
Kgothatso Ngako
2f06485ec5 Update to make use of lightning-kmp-appp 2026-08-02 20:43:30 +02:00
Kgothatso Ngako
e9510c4ea5 Remove phoenix code 2026-08-02 18:58:36 +02:00
Kgothatso Ngako
7d893c7a79 Bug fix for when new member is added. 2026-07-28 11:50:55 +02:00
Kgothatso Ngako
62233f0fd8 Try to broadcast in specific order 2026-07-28 09:50:37 +02:00
Kgothatso Ngako
180b8c5aa5 Remove scaffolding 2026-07-28 09:30:35 +02:00
Kgothatso Ngako
6e13938478 Add and broadcast translation chunks version 2026-07-28 09:29:15 +02:00
Kgothatso Ngako
02b2c9a4f8 Add and broadcast translation artifact version
We might not want to do translation chunk scaffolding
2026-07-28 09:21:16 +02:00
Kgothatso Ngako
8de05742d0 Add and broadcast chunked chapters 2026-07-28 09:10:20 +02:00
Kgothatso Ngako
83f777a758 Add chat messages for mantra logic 2026-07-28 01:59:28 +02:00
Kgothatso Ngako
eefcbaa157 Bug fix for inner events. 2026-07-28 01:39:32 +02:00
Kgothatso Ngako
33c4d1000f Send artifacts inner events 2026-07-28 00:52:21 +02:00
Kgothatso Ngako
0c65f61ea2 Fix chat initiation between profiles created in the app
A profile created in the app could never start a chat with another
profile created in the app: the peer always looked like it had no
metadata, no DM relay list and no MLS key package.

Root cause was in neither the chat code nor the relay list — nothing a
new profile signed ever reached a relay. Three queue observers used
`createdAt > :createdAt` with a `Clock.System.now()` default argument.
Kotlin evaluates that default once, at the call site, and Room binds it
for the life of the Flow; Instants persist at second resolution, so
every request enqueued in the observer's own start second (the whole
profile-creation burst) and everything left pending by a previous
session was permanently invisible. Nothing else drains those tables.
The failure was silent because `publishNostrEvent` stamps `signedAt`
and indexes the Profile in one transaction, satisfying the
ProfileLoaded branch before the UnannouncedProfile gate could be
reached — so a device-only profile looked fully announced.

Dropping the cutoff needs no schema change, so existing installs
self-heal on next launch: the stranded rows are still pending.

Also fixed, since they gate the same flow once events start moving:

- Broadcasts now always reach a terminal status (outer timeout plus
  try/catch — `.catch` cannot see the suspend call that builds the
  flow), interrupted ones are requeued once at startup, `OK: false` is
  a failure rather than a recorded success, fan-out is bounded, and an
  uncorrelated NOTICE no longer fails whatever publish shares the
  socket. `take(1)` keeps the publish timeout from firing after a
  success on a SharedFlow that never completes.
- CLOSED is parsed and handled, so a relay refusing a NEG subscription
  falls back to REQ instead of waiting forever; NOTICE is parsed as
  the two-element frame it is; negentropy timestamps use seconds, the
  unit relays use.
- Both chat gates observe the peer's key package instead of reading it
  once and latching a terminal error, and queue the sync they claimed
  to be doing. Same-minute retries are no longer swallowed by IGNORE.
- Group rooms were keyed by the MLS group id instead of the Marmot
  nostrGroupId (unrelated randoms, so neither side saw the other's
  events); inviting a member wrote no Participant row, so the Welcome
  produced no gift wraps, and discarded the post-addMember group state;
  the invite reported success unconditionally.
- An inverted `containsKey` made the "missing peer DM relay list"
  recovery a no-op, and the wrong RelayTag class wrote "r" tags where
  NIP-51 relay lists expect "relay".

Verified with `:composeApp:compileDebugKotlinAndroid`, including that
Room's KSP regenerated the DAO impls without the frozen cutoff. Not yet
exercised against live relays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:51:47 +02:00
Kgothatso Ngako
c0897645ae Merge branch 'claude/sweet-keller-9c933e' into artifacts 2026-07-27 01:23:14 +02:00
Kgothatso Ngako
3356e58c06 Fix notary observer lifecycle and robustness bugs
- Observers now run as children of collectLatest keyed on the derived
  nostr private key: previously every active-wallet emission spawned
  four more eternal collectors on the app scope, and after a wallet
  switch stale collectors kept signing with the old key (duplicate
  signatures, gift wraps and key package bundles).
- Follow the keyManager StateFlow instead of snapshotting .value, so
  the notary still starts when the key loads after the wallet emits.
- Guard per-item processing so one failing row logs instead of killing
  the collector (and the queue) for the rest of the session.
- Derive the real nsecPassword for self-healed key package bundles via
  a new PrivateKey.nsecPassword() extension instead of passing "".
- Fix a copy-pasted log tag in observeUnprocessedMarmotInnerEvents.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 01:22:40 +02:00
Kgothatso Ngako
2259b25f3f Remember scopes and repositories in MantraNavHost
The coroutine scopes, AuxDatabaseManager, and repositories were
created as plain vals in the composable body, so every recomposition
recreated them — leaking the old scopes' jobs and duplicating
repository instances. Wrap them in remember so they are created once
per composition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 01:21:56 +02:00
Kgothatso Ngako
0503464f6a Fix profile creation bugs
- platformWriteSeed (Android + iOS) invoked onSeedWritten twice on
  success, doubling wallet switch/navigation and navigating from the
  IO dispatcher; keep only the main-thread invocation.
- Gate profile event creation on the seed actually being written to
  disk: writeSeed now reports success/error, so a failed seed write no
  longer leaves orphaned unsigned events the notary can never sign.
- Stop rethrowing from createAccount's CoroutineExceptionHandler
  (crashed the app); failures now show the Error state and reset the
  pending flag instead of spinning forever. Add a re-entry guard
  against double taps.
- Reset WritingSeedState after a completed attempt so retries are not
  silently skipped, and record WrittenToDisk on success.
- Derive the nostr key with NodeParamsManager.chain instead of a
  hardcoded Chain.Mainnet.
- Build the SearchRelayListEvent from DefaultSearchRelayList instead
  of DM relays, and drop its empty privateTags array that caused a
  pointless NIP-44 encrypted empty list in content.
- Compare pubKey, privateTags and signedAt in UnsignedNostrEvent
  equals/hashCode so distinctUntilChanged cannot conflate rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 01:09:50 +02:00
Kgothatso Ngako
5e92e7f613 Minor bug fixes 2026-07-27 00:52:22 +02:00
Kgothatso Ngako
57652b98e1 Merge branch 'claude/admiring-mahavira-a862d1' into artifacts
# Conflicts:
#	composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt
2026-07-26 03:04:33 +02:00
Kgothatso Ngako
5621493b98 Open the new artifact after adding it
On a successful add-artifact, navigate to the artifact detail for the newly
created artifact instead of the implementation-pending screen. The ViewModel
now passes the created artifact's id (the returned inner event's id) to
onSuccess, and the screen opens ArtifactDetailRoute via the pop-inclusive
callback so the add form leaves the back stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 03:03:10 +02:00
Kgothatso Ngako
66aabc24e0 Merge branch 'claude/admiring-mahavira-a862d1' into artifacts 2026-07-26 02:50:25 +02:00
Kgothatso Ngako
5973ab1ca5 Reorder translation detail: chapters first
Move the Chapters section above the Details section on the
TranslationArtifactVersionDetailScreen so the actionable content leads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:50:06 +02:00
Kgothatso Ngako
1391462640 Merge branch 'claude/admiring-mahavira-a862d1' into artifacts 2026-07-26 02:46:48 +02:00
Kgothatso Ngako
3370e354c3 Reorder artifact detail: chapters and translations first
Move the Chapters and Translations sections to the top of the artifact
detail screen and the descriptive Details and Versions sections to the
bottom, so the actionable content leads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:46:16 +02:00