Commit Graph

495 Commits

Author SHA1 Message Date
Kgothatso Ngako
f5eb744ca7 test: cover the long-running sync, and open the seams needed to do it
The six commits that built the live chat sync added no tests. Everything they
touch fails silently by nature — a filter that drops messages, a subscription
that stops being replayed, a group whose id never reaches the `#h` tag — so the
symptom is always "some messages didn't arrive", days later, on someone else's
phone. 46 tests, in four files.

**What is covered**

RelayPoolSubscriptionTest (13) — the pool's half of surviving a dropped socket.
A query is retained and replayed on reconnect; a closed one is forgotten and
stops the socket reconnecting for it; closing one of two leaves the other alone;
a negentropy exchange is never replayed (its rounds are stateful, so resuming
one reconciles against a conversation the relay is no longer having); an update
to a live subscription replaces what gets replayed, including when the send
itself fails; dropping a relay or closing the pool forgets what they carried;
replay is scoped to the relay that reconnected. Plus the semantic the whole
change rests on, asserted in both directions: a live subscription keeps
delivering after EOSE, a one-shot query still ends at it.

LiveSubscriptionReconcileTest (12) — the requirement this all exists for: the
group filter follows group membership with nobody calling a subscribe function.
Joining widens the filter *in place* rather than reopening (a reopen would drop
the live tail of every other group in that chunk); leaving drops one; leaving
everything closes the subscription; churn inside the debounce window collapses
to one update; a NIP-17 room never becomes a group subscription. Then the
collect loop: events stored against the relay they came from, an event after
EOSE still stored, a CLOSED reopened once the back-off elapses and not before,
and a rate-limited CLOSED waiting far longer — but still coming back.
Backgrounding closes and foregrounding rebuilds, reconnects, and queues the
catch-up.

LiveSubscriptionPlanTest (11) — the filter and planning rules, led by the one
most likely to be "tidied up" later: the gift wrap filter carries no `since`,
because NIP-59 randomizes created_at into the past and a `since` near the
present silently drops new messages.

RelayBackPressureTest (4) and ReconnectBackoffTest (6) — the two pure decisions.
Which CLOSED reasons mean "ease off", and the backoff arithmetic including the
exponent clamp: 2.0.pow(4000) is Infinity and Duration * Double throws on it, so
without it a socket failing long enough turned its reconnect loop into a crash
loop, at the point the network was least likely to recover unaided.

**Seams opened to get there**, each a readability win on its own terms:

  - NostrSocketClientFactory becomes an interface with DefaultNostrSocketClientFactory
    behind it, so the pool can be driven by a fake socket.
  - RelayPool takes its CoroutineScope, so the replay a reconnect triggers can be
    observed rather than raced.
  - LiveSubscriptionManager depends on a new LiveSubscriptionTransport (4
    methods) rather than RelaysSocketManager, which observes the active wallet in
    its init and cannot be stood up in a test at all.
  - Its pure planning helpers move to the companion as `internal`, and its
    launches inherit the caller's dispatcher instead of pinning Dispatchers.IO.
    SynchronizationViewModel already launches observe() on IO, so nothing moves —
    but a coroutine that picks its own dispatcher cannot be driven by a test
    scheduler.
  - reconnectDelay is extracted to ReconnectBackoff.kt with jitter as a
    parameter, so the arithmetic can be pinned without randomness.
  - endsLiveSubscription names the live-subscription termination rule next to
    isTerminalFor, which is the one-shot rule. Having both named makes the
    difference between them reviewable rather than implicit.

kotlinx-coroutines-test is added to commonTest: the pool's bookkeeping is all
suspend functions and there is no runBlocking in a common source set.

The tests were checked by mutation, not just by passing — reintroducing a
`since`, making EOSE terminal, dropping the leftGroupAt filter and removing
retention from query() each produce failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:31:11 +02:00
Kgothatso Ngako
ca1093637c refactor(chat): stop syncing chat messages on screen open
The point of the previous four commits. Both chat screens scheduled a message
sync every time they were opened; both are now covered by subscriptions that are
already open, so the sync on open is work with nothing left to do.

**ChatRoomListViewModel** no longer syncs on initiate(). scheduleSynchronization
itself stays, and is unchanged: it is exactly the reconciliation
LiveSubscriptionManager runs when the app returns from the background, and it is
what an explicit user-initiated refresh should call. That is the one case the
live tier genuinely does not answer, because it is the user saying they believe
something is missing.

**ChatMessageListViewModel** loses its message sync entirely. The MLS branch
(negentropy over 445 h-tagged with this one room) is a strict subset of
live-groups-*. The NIP-17 branch's gift wrap sync is a subset of live-giftwrap.
What survives is discovery rather than sync: if we do not hold a participant's
kind-10050 we cannot address a message to them, and that is worth resolving the
moment a chat is opened rather than whenever a background pass reaches it. The
function now does only that, and only when the relay list is actually missing —
it used to queue a request in both branches of that test.

**The dead "sent-messages" reconciliation is deleted**, in the view model and at
both sites in NostrDao. It asked for kind 1059 with authors=[userPublicKey], and
could never match a single event: a gift wrap is signed with a throwaway KeyPair
(DatabaseChatRepository), so its pubkey is random and never ours. It was also
unnecessary — createNip17ChatRoom puts us in our own participant list, so we
wrap a copy to ourselves and the live gift wrap subscription picks our own sent
messages up on every device.

Removing it inverts the surrounding test in NostrDao from `if (relayList !=
null) { sync } else { discover }` to `if (relayList == null) { discover }`. The
discovery half is untouched.

Net effect on a session: opening the chat list queues nothing, opening a chat
queues at most a kind-10050 lookup for a participant we cannot yet address, and
messages arrive because a subscription is open rather than because a screen
asked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:18:28 +02:00
Kgothatso Ngako
0ef0a33350 feat(chat): close live subscriptions in the background, catch up on return
Subscriptions that stay open are only free while the app is on screen. Holding a
socket open behind a doze window achieves nothing but battery, and the relay
drops the subscription anyway — so this ties them to the app lifecycle and adds
the reconciliation that covers the gap.

Nothing in this app observed the app lifecycle at all: MainActivity only calls
setContent. AppLifecycle is a small singleton holding an isForeground StateFlow,
fed from a LifecycleEventObserver in MantraNavHost (ON_START/ON_STOP), and read
by LiveSubscriptionManager. A singleton rather than something threaded through
the composition because the consumers are not composables — they are
application-scoped coroutines started before any screen exists and outliving all
of them. It defaults to foreground: on a platform that has not wired the
observer up, "always on" is the behaviour that predates this file, and a
subscription that never opens is a far worse failure than one that stays open
too long.

collectLatest over that flow is the entire mechanism. Backgrounding cancels the
block holding the subscriptions, and each one's finally sends its CLOSE and
releases the retained REQ on the way out — which is also what tells the socket
it no longer has a reason to reconnect.

On the way back:

**Reconnect before asking for anything.** RelayPool.reconnectAll tears every
socket down and immediately rebuilds it. Trusting the connection is the mistake
here: a socket that was open when the OS suspended the process reports itself
connected on the way back while being functionally dead. Re-opening eagerly
rather than leaving it to the next send is deliberate — it is what makes this a
RE-connection, so retained subscriptions are replayed and any collector still
attached from before the gap starts receiving again. That also covers queue
requests that were mid-flight when we went away, which would otherwise sit until
SUBSCRIPTION_TIMEOUT.

**Then a catch-up reconciliation.** A live subscription answers "what is new
since I connected"; negentropy answers "what do you have that I don't". Coming
back from a gap is exactly the question only the second can answer — `limit` on
the re-opened subscriptions is a window, not a guarantee. queueCatchUpSynchronization
queues the same two negentropy requests ChatRoomListViewModel queues on open:
gift wraps p-tagged to us, and group events h-tagged with every group we are in.

Deliberately the same filter shape as the screen's, down to limit=50. A
negentropy request is stored under a hash of its filter, so an identical shape
collapses into one row instead of queueing the same reconciliation twice while
both callers exist. The value itself barely matters — the negentropy pump drops
`limit` outright and it only survives into the plain-REQ fallback.

The room-to-group-id rule (has MLS state, not left, not deleted, sorted) now
lives in one place, since the catch-up and the subscription reconcile have to
agree on what "a group we are in" means.

Not covered here: connectivity changes. A network switch mid-foreground is still
only noticed by the socket's own reconnect loop, which handles the common case
but cannot know the network changed underneath it. That wants a platform
connectivity observer, and is its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:15:51 +02:00
Kgothatso Ngako
f81dc6af69 feat(chat): keep group subscriptions open, and follow membership as it changes
The second live subscription: kind 445 h-tagged with every Marmot group we
belong to, chunked 100 group ids to a filter, open on every DM relay. Group
messages now arrive whether or not that chat is on screen — or any chat.

**Derived from the room list, not from the join sites.** The filter comes from
chatRepository.observeChatRoomListByPublicKey, the same Flow the chat list
screen renders, collected at wallet scope so it keeps running with no chat
screen open. That is the whole reason this stays correct: a group id can appear
four ways, and only one of them is somewhere anyone would think to call a
subscribe function.

  - we create a group (MarmotOutboundDao),
  - we are added to one — the Welcome arrives inside a gift wrap and is
    processed deep inside NostrDao.storeNostrEvent, on the inbound path,
  - membership shifts under us via a commit
    (MarmotInboundManager.processGroupMembershipChanges),
  - we leave, or the room is deleted.

Observing the table catches all four, and it closes the loop with the previous
commit: a Welcome lands on the gift wrap subscription, a ChatRoom row is
written, this flow re-emits, the group filter widens — and the first message in
a group we were just added to arrives without anyone opening a chat.

Rooms are filtered to those with mlsGroupState (a NIP-17 room has none and is
served by the gift wrap subscription instead) and without leftGroupAt or
deletedAt — a room we have left keeps its history locally but must stop pulling
new messages. Ids are sorted before distinctUntilChanged so the same membership
in a different row order is the same value, then debounced 500ms: joining a
group writes the room, its participants and placeholder profiles in quick
succession, each of which re-emits the list.

**Reconciling, not rebuilding.** A chunk that already has a subscription is
updated in place — the relay replaces that subscription's filter on a repeated
REQ with the same id — so adding a group does not interrupt delivery on the
groups already being watched. Only chunks that no longer exist are torn down,
and they are cancelAndJoin'd rather than cancelled: the CLOSE is sent from the
coroutine's finally, and returning before it lands would let a later reconcile
open a subscription on that id which the old one then closes out from under.

The filter is read from a supplier at every open rather than captured at launch,
so a subscription re-opened after a CLOSED comes back with the current
membership rather than the membership it was created with.

**No `since` here either, but for a different reason.** Group events do carry
honest timestamps — MarmotOutboundDao stamps them with TimeUtils.now(), unlike
gift wraps — so a watermark would be safe. It is still not used: limit already
bounds the initial burst, and a watermark would have to be recomputed every time
a chunk's membership changed. The group limit is 500 rather than the gift wrap
subscription's 100, because one chunk covers up to 100 conversations.

Also in here: RelayPool.updateLiveSubscription now retains the new REQ before
attempting to send it, and treats a missing or failing socket as "send it on
reconnect" rather than throwing. Throwing would have left the OLD filter
retained, so a reconnect would restore a subscription the caller had already
moved on from — the one outcome worse than not sending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:12:40 +02:00
Kgothatso Ngako
ba05ec704d feat(chat): keep a gift wrap subscription open for as long as the app is active
The first live subscription: kind 1059 p-tagged to us, on every DM relay, open
until the wallet changes or the process ends. That covers direct messages and
the Marmot Welcome events that make us a member of a group — so a DM now lands
whether or not anyone has the chat list open, and a group invite is noticed
without waiting for the next screen to schedule a sync.

Nothing is removed from the queue yet. ChatRoomListViewModel and
ChatMessageListViewModel still schedule what they always did; this runs beside
them and the duplicate events cost a no-op storeNostrEvent each. The screens
stop doing it in the last step, once groups are covered too.

**No `since`.** NIP-59 randomizes a wrap's created_at into the past, and our own
outbound path stamps them with TimeUtils.randomWithTwoDays() — so a wrap
published right now can carry a timestamp two days old, and a `since` anywhere
near the present would silently drop it. "Some messages just never arrive" is
the worst failure mode to debug, and re-receiving a wrap costs one no-op write.

**`limit` instead.** NIP-01 scopes limit to the initial query — the stored
events a relay sends before EOSE — and explicitly not to the stream that
follows, so limit=100 bounds what a reconnect costs without touching the live
tail. Filling in the rest of the history stays negentropy's job.

**What ends a subscription, and what doesn't.** CLOSED does, and is retried with
backoff from 5s to 5m, jumping straight to the maximum when the reason parses as
back-pressure — answering "too many subscriptions" by promptly opening another
is how one refusal becomes a flood. EOSE does not end anything: it is the
boundary between stored history and the live tail, and treating it as an end is
precisely what made every sync a poll. It does reset the backoff, since a
subscription that got that far was working and whatever ended it is a new
problem. A dropped socket needs nothing from this loop at all — the pool replays
the REQ on reconnect and the collector, attached to the socket client rather
than to a session, simply starts receiving again.

A NOTICE is logged and otherwise ignored. It carries no subscription id, so it
reaches every collector on the socket; acting on one here would let an unrelated
relay complaint tear this subscription down.

Events are saved inline rather than in a launched coroutine, so they land in the
order the relay sent them and the socket's buffer does the back-pressure,
instead of fanning a burst out into a coroutine per event.

Wiring: LiveSubscriptionManager is built by SynchronizationViewModel because it
has to share that class's RelaysSocketManager — a second one would mean a second
RelayPool and a duplicate socket per relay — and is launched inside the same
per-wallet supervisorScope as the three pumps, so a wallet switch tears it down.
It deliberately does not take from the pumps' Semaphore(4): a subscription that
never finishes would hold a permit forever and permanently halve backfill
throughput. The comment there now says so.

isBackPressure moves out of SynchronizationViewModel to
network/relays/RelayBackPressure.kt, unchanged, now that two callers need it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:09:08 +02:00
Kgothatso Ngako
3ade53607a feat(relays): plumbing for subscriptions that are meant to stay open
Three pieces, none of which opens a subscription yet — the manager that does
lands next.

**A subscription that survives EOSE.** RelayPool.openLiveSubscription is query()
without completeOnSubscriptionEnd. For a one-shot request EOSE is the end: it is
what finishes the collector, frees the subscription slot and triggers the CLOSE.
For a live subscription EOSE is only the boundary between stored history and the
live tail, and the relay owes us nothing further to mark an end — so the flow
ends when, and only when, the caller stops collecting.

The returned flow is attached to the socket CLIENT's message flow rather than to
a session, so it survives a drop: the socket reconnects, the previous commit's
replay re-sends the REQ, and the same collector starts receiving again with
nothing rebuilt. updateLiveSubscription re-sends under the same subscription id
to widen or narrow the filter in place — a relay answers a repeated REQ on an
existing id by replacing that subscription's filter, so there is no
close-and-reopen and the collector never notices.

**Ordered, buffered inbound.** The socket's incomingMessages was a rendezvous
SharedFlow emitted into from a coroutine launched per message. Two consequences a
short request/response collector never noticed, and a permanent one would:

  - messages reached collectors in whatever order those coroutines happened to be
    scheduled, and
  - an emit with every collector busy blocked on the slowest of them.

It now has a 256-message buffer and is emitted into inline, on the reader, in
wire order. That also lets the 75ms sleep before every EOSE go: it existed to
hope that the events preceding an EOSE had already been delivered by their own
coroutines, which ordering now guarantees outright.

The buffer is the back-pressure boundary — a collector may fall 256 behind
before it slows its socket's reader down. Deep enough to absorb the burst
between a REQ and its EOSE while a collector writes each event to SQLite, not so
deep that a stuck collector is invisible.

**A saveNostrEvent that does not need a request row.** Both existing overloads
take the row an event was fetched for, because they also record which request
produced it and flip that row to "processed". A live subscription has no row and
never finishes, so the new overload carries relayURL and level itself and goes
straight to storeNostrEvent. Everything downstream — indexing, gift wrap
unwrapping, Welcome handling, MLS decryption — is unchanged.

Fixed while adding it: the negentropy overload was writing outside
storeNostrEventMutex while the other one held it. storeNostrEvent reads an event
and then writes it and its indexes, so two of those interleaving is a lost
update. Survivable while a single queue was the only writer; not survivable with
a live subscription writing alongside a backfill.

Deviation from docs/long-running-sync.md worth noting: the doc proposed one
permanent collector per socket dispatching by subscription id prefix. With the
inbound flow now buffered and only a handful of live subscriptions per relay, a
collector per subscription has the same properties for less machinery. If the
live subscription count per relay ever grows, the router is the next move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:05:19 +02:00
Kgothatso Ngako
c3b0c8671b feat(relays): reconnect dropped sockets and replay what they were carrying
Nothing in this app reconnected a websocket. NostrSocketClientImpl caught a
failure, called close(), and fired onSocketConnectionClosed — which only flips a
boolean in relayPoolStatus that nobody reads. A socket that failed stayed
failed, and the only reason that was survivable is that every subscription is
short: the next queued request opens a fresh socket on its way out through
ensureSocketConnectionOrThrow.

That stops being survivable the moment a subscription is meant to outlive the
socket, so this lands first, on its own. It is already a fix without any of
that: a REQ interrupted mid-download used to sit there until SUBSCRIPTION_TIMEOUT
gave up 120s later, having saved whatever partial set arrived before the drop.
Now the socket comes back and the REQ is re-sent.

The socket client:

  - a supervised reconnect loop with exponential backoff (1s doubling to 60s)
    plus up to 25% jitter, because every relay in the pool drops at once when
    the network does and without jitter they all come back in lockstep. The
    exponent is capped so a socket failing for hours cannot overflow the
    doubling into Infinity, which Duration * Double rejects outright.
  - `autoReconnect`, off by default and owned by the pool. Reconnecting a socket
    nobody is subscribed on is battery spent on nothing, so the pool turns it on
    for exactly as long as it retains a subscription for that relay.
  - `closedByClient`, so closePool() is not answered by every socket in it
    politely reconnecting. Cleared by the next caller-driven connect.
  - onSessionLost() as the single exit point for a session that ended without
    the client asking, replacing the close()-from-inside-the-receiver dance. It
    identity-checks the session before clearing it, so a reconnect that already
    installed a newer one is not torn down by its predecessor's cleanup, and
    runs NonCancellable because the receiver job is cancelled as part of a
    replacement connect.
  - Frame.Close now breaks the receive loop rather than closing by hand. The
    relay closing us is not the client closing us, so it earns a reconnect too.
  - a new SocketConnectionReopenedCallback, fired only when a session is
    established on a socket that had connected before. Kept separate from
    "opened" because on a FIRST connect a replay would double-send the very REQ
    whose sendMESSAGE opened the socket.

Two bugs fixed in passing, both of the silent kind:

  - the compression REQ in the post-connect handshake was written to `wsSession`
    before the new session was assigned to it, so it went to the previous
    (usually null) session and was dropped. wsSession is now assigned first.
  - sendMESSAGE used `wsSession?.send(...)`, so a send on a dropped socket was a
    no-op and the caller waited forever for an answer to a message never sent.
    It now warns.

The pool:

  - retains the REQ text per (relay, subscription id), and replays it when that
    relay's socket is re-established. A relay answers a repeated REQ on the same
    subscription id by replacing the filter, so replay is a send rather than a
    close-and-reopen, and the collector already attached to the socket's message
    flow simply starts receiving again.
  - retains on query() BEFORE the send, so a socket that dies between there and
    the relay's first answer is still covered; releases on closeQuery(), which
    every pump already calls from a NonCancellable finally.
  - deliberately does NOT retain negentropy. NEG-OPEN carries a fingerprint of
    the local set and each round depends on the last, so replaying one
    mid-exchange would reconcile against a conversation the relay is no longer
    having. An interrupted negentropy request is abandoned and re-queued.
  - drops retained work for relays removed by changeRelays/removeRelays/
    closePool, so a relay edit does not leave a socket reconnecting for
    subscriptions nobody wants.
  - collapses the five hand-rolled `socketClients.find { normalize... }` lookups
    into socketClientFor(), now that there were about to be several more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:02:11 +02:00
Kgothatso Ngako
907dba3c3b fix: create the #admins room only once every member can be added
Every member's key package is now resolved before anything is created. If one is
missing the room is not created at all, and the coordinator is told which member
to go and ask rather than being handed a room quietly short of people.

Previously the room was created and then whoever could be added was added, with the
rest collected into a list that only reached the log. Two things make that the wrong
trade here, and neither applies to ordinary group creation:

MarmotGroupData.adminPubkeys is baked into the epoch-0 GroupContext and names every
member of the ceremony. A room created without one of them therefore lists an admin
who is not in the MLS tree -- a group that disagrees with itself from its first
epoch, and MIP-01 leans on that list for most group operations.

And the id is derived from the shared key, so there is exactly one room per group at
this path. A half-created one occupies that address permanently; unlike a random id
there is no second one to retry with. Creating nothing leaves the retry clean.

The lookup moves ahead of group creation, which also means the batched add now
receives a list it knows is complete -- `addMembers` no longer has to reason about
absent key packages on this path.

`inviteAdmins` goes with it. Its job was resolving key packages and then adding
whoever it could; the first half moved into the precondition and the second is a
direct `addMembers` call.

The blocked members surface as `DkgRitualUIState.adminGroupBlockedOn`, carrying
names rather than public keys -- the action this prompts is asking a particular
person to open the app, so a name is what the coordinator needs. Cleared when the
button is pressed again, so a retry does not show the previous answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 15:12:06 +02:00
Kgothatso Ngako
3dea07135c fix: add a group's whole membership in one commit, closing the epoch race
`MarmotOutboundDao.addMembersToChatRoom` stages every member with `proposeAdd` and
issues a single `commit()`. Both callers that know their membership up front now
use it: `SelectChatRoomTypeViewModel.inviteMembers` at room creation, and
`DkgRitualViewModel.inviteAdmins` for the #admins room.

Inviting one at a time created an epoch per member, and each of those commits
raced the previous member's welcome. MarmotInboundManager refuses future-epoch
messages outright, on both wire formats, with no queue and no replay -- so the
member who lost that race was silently stuck an epoch behind while the caller saw
a successful invite. Deriving isOneMemberInitialGroupCreation narrowed that window;
this removes it. No member ever has to process a commit for an epoch they were not
yet in, so there is no longer a race to lose.

One commit yields one welcome: `buildWelcome` emits an EncryptedGroupSecrets per
added member and each joiner finds its own entry by key package reference. The blob
is shared, delivery stays per peer, because each welcome event is tagged with that
peer's key package.

## Why this needed no schema change

Batching at creation time means the single commit happens while the group is still
only its creator, which takes the immediate-welcome branch: nothing is broadcast
and MarmotCommitResult is never written. The bookkeeping that assumes one peer per
commit is simply not on this path.

So the batch is taken only when `members().size == 1`, and anything else falls back
to inviting sequentially -- correct, if not ideal. Batching into an established
group would take the deferred branch, where `peerKeyPackageEventId` is singular and
the ack-triggered delivery in DatabaseNostrRepository expects one welcome; making
that work needs a list there and a fan-out on acknowledgement. Nothing currently
adds several members to an established group, so that is left outstanding and
documented rather than speculatively built.

The group state is persisted after `commit()` and before any welcome goes out, so a
crash between them leaves the group at the epoch the welcomes describe rather than
one behind it.

## Reporting

Members with no published key package still cannot be added -- a Marmot invite
needs one -- and are now returned alongside any that failed to receive their
welcome, rather than the two being conflated. Both still only reach the log; the
coordinator is not yet told.

docs/marmot-membership.md is updated in the same change: batching moves from
outstanding work to described behaviour, with the schema constraint that shapes it
and the remaining fan-out work recorded. The note about sequential invites is
narrowed to where they still happen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 15:02:06 +02:00
Kgothatso Ngako
8fc1c9e650 fix: stop deferring the first invitee's welcome behind a commit nobody needs
`inviteMember` now works out for itself whether the group it is adding to has
anybody to inform:

    val isOneMemberInitialGroupCreation = mlsGroup.members().size == 1

read before `addMember` advances the tree. The parameter is gone from the
signature and no caller passes it any more.

Callers were the wrong place for this decision and both of them got it wrong.
`inviteMemberToChatRoom` hardcoded `false`, and `ChatRepository.inviteMember` did
not expose it at all, so every invite made through a group -- room creation in
SelectChatRoomTypeViewModel, and the #admins room -- took the deferred-welcome
path. That includes the first invite, when the group is still only its creator, at
which point:

  - the commit has no audience. No other member exists, and nobody outside the
    group can decrypt it, so it is noise on the relay.
  - the welcome is then withheld until a relay acknowledges that noise. If the ack
    never lands, the first invitee receives nothing at all.

Only createMlsDirectMessageChatRoom passed `true`, and only because a DM has
exactly one invite. A group of n has one such invite too -- the first -- and it was
not getting it.

The condition is right at any size, not just for DMs: "the group has nobody to
inform" is true exactly once. Invite two sees one member who must advance, invite
three sees two, and so on. Their commits are encrypted with
`commitResult.preCommitExporterSecret`, the epoch the earlier invitees received in
their own welcome, so they can decrypt and advance. `members()` skips empty leaves,
so this also stays correct for a group that has had members removed.

## What this does and does not fix

It removes a pointless commit and, with DefaultDMRelayList now a single relay, a
single point of failure sitting in front of every group's first member.

It also narrows a silent race rather than closing it. MarmotInboundManager refuses
future-epoch messages outright on both wire formats -- no queue, no replay -- so a
commit arriving before its recipient's welcome is dropped and that member never
advances, while the coordinator sees a successful invite. Previously both commits
went out before either welcome; now welcome 1 is sent before commit 2 exists, so
the first invitee is already at the right epoch. For n >= 3 the window between
welcome 1 and commit 2 remains.

Closing it needs the adds batched into one commit, which is the outstanding work
described in docs/marmot-membership.md. That doc is updated here to describe the
derived flag as current behaviour rather than a proposal, and to keep batching as
the remaining item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 14:44:28 +02:00
Kgothatso Ngako
9f14679aac feat: let the coordinator open a #admins room keyed on the shared key
Once a ceremony completes, the shared-key screen offers its coordinator a Marmot
room named "<group> (#admins)" with every member of the ceremony in
MarmotGroupData.adminPubkeys. The room the ceremony ran in is NIP-17, where nobody
administers anything; this gives the same people a room where every one of them
can act, which is the shape a group that has just made a t-of-n key is asking for.

Built directly rather than through MarmotGroupData.bootstrap, which hardcodes a
single admin, and baked into the epoch-0 GroupContext so later invitees receive a
populated group from their welcome instead of chasing a bootstrap commit that
predates their membership.

## The id is derived, not random

Every other Marmot room mints `nostrGroupId` as RandomInstance.bytes(32). This one
derives it from the group's threshold key, settling the
`// TODO: Generate GID through frost...` already sitting in
SelectChatRoomTypeViewModel.

Derivation buys two things random cannot. Every member's device can compute the id
from a ceremony they all took part in, so the room is addressable without being
announced; and two members racing to create it arrive at the same id rather than
two rival rooms -- which is why createAdminGroup returns to the existing room
instead of minting a second one.

## Why the derivation is what it is

SharedKeyDerivation walks the path as successive FROST tweaks, one per index,
returning both the XonlyPublicKey and the TweakCache. The cache is not an
optimisation: a signing session created without the same tweaks aggregates to
signatures that verify against a different key, which is why the id is usable as
an identity later rather than only as a label.

It is not BIP32, and the doc comment argues that at length rather than leaving it
to be rediscovered. A BIP32 node is a key *and* a chain code; ChillDKG produces no
chain code. BIP32 wants one only because it computes the tweak scalar for you, and
a FROST tweak takes that scalar as an input -- so choosing it directly removes the
chain code from the problem rather than requiring one to be invented and agreed
forever. It also removes a trap: with x-only keys there is no single obvious
serP(K_par), and two devices picking different parity conventions would silently
derive different keys rather than fail.

Each scalar commits to the key being tweaked as well as the index, so steps cannot
be reordered or replayed at a different depth. Tests cover that, determinism
across calls, path and key sensitivity, and that the cache and the public key
agree.

Hardened derivation is not available here and never will be: it needs the parent
private key, which in a threshold group nobody has. That leaves the non-hardened
weakness -- k' = k + t with publicly computable t inverts -- so anyone learning one
derived private key recovers the group key and can sign with no quorum at all. The
rule that follows is stated at the top of the file: never reconstruct a derived key
in the clear.

## The path is recorded in the room

MIP-01's group data is a fixed TLS schema with no extension map, so a custom field
would emit bytes other Marmot clients cannot decode. The path rides in the
description instead, on its own line under a marker, so somebody rewriting the
rest of the description does not cost the group the record of how its key was
derived:

    Admins of Ubuntu Collective.

    Shared key path: m/9420/0/0

Worth storing although the path is currently a constant: it is what rebuilds the
TweakCache a signing session needs, and recomputing from the constant only holds
while the constant never changes. parsePath refuses hardened indices rather than
tolerating them -- such a path cannot have been walked here, so acting on one
would derive something other than what the room claims.

## Known limits

Members without a published MarmotKeyPackage cannot be invited; inviteAdmins
collects them and logs them, and the coordinator is not yet told.

Invites go one at a time, each advancing the MLS epoch, so the room is re-read
between them. That inherits a silent failure mode documented in
docs/marmot-membership.md: the first invite takes the deferred-welcome path even
though the group is still just its creator, and a commit reaching a member before
their welcome is dropped rather than queued. Not introduced here -- group creation
has always done this -- but more visible in a room whose whole membership is known
up front.

Nothing here has run on a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 14:38:58 +02:00
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
61869f0046 test: run a real ChillDKG ceremony through the ritual's ordering rules
ChillDKG has no session-params object the group agrees on out of band: every step
takes the host public keys and the threshold and hashes them into the session
identity itself. A group whose devices order their participants differently
therefore gets no key at all, and nothing in the protocol tells you that is what
went wrong. ChillDkgRitualManager has each device derive that order
independently -- sort the collected host keys, and order each round's messages by
their sender's host key to match -- and until now nothing checked that the two
rules agree, or that they agree with what ChillDKG expects.

Four tests, against the real library rather than a stand-in:

  a ritual ordered by host key produces one shared key
      A full 2-of-3 run -- step1, coordinatorStep1, step2, coordinatorFinalize,
      participantFinalize -- with the participant set built by hostPublicKeys()'s
      rule and both rounds ordered by orderedPayloads()' rule. Asserts every
      member lands on the same threshold public key and on distinct shares.

  sorted host keys give every device the same participant order
      The same members in three arrival orders, since relays deliver host keys in
      whatever order they please, must sort to one order.

  one device ordering participants differently gets no key
      The negative that keeps the other two honest: with one member running the
      same people in another order, some step has to fault. Without this a broken
      ordering rule could pass the happy-path test by being uniformly broken.

  host keys are not the nostr keys they come from
      deriveHostSecretKey's two obligations: it must not hand ChillDKG the nostr
      identity key (a flaw in either protocol would otherwise reach the other),
      and it must be deterministic, or a reinstall cannot recover the share.

These live in commonTest and run under `./gradlew :composeApp:testDebugUnitTest`.
The secp256k1 natives do load there: the Android loader fails and falls back to
extracting the JVM platform build, so these are real curve operations, not
mocked ones. Room-backed code still cannot be tested this way, which is why the
manager's database behaviour is not covered here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:14:00 +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
a611277d5d build: drop six unused dependencies and their catalog entries
None of these are imported by any source file in composeApp, generated sources
included, and none is the kind of dependency that gets used without an import.

  androidx.paging:paging-common    no androidx.paging import anywhere; no DAO
  androidx.paging:paging-compose   returns PagingSource and nothing calls
                                   collectAsLazyPagingItems, so the Room-Paging
                                   integration that would need it is not in use
  androidx.work:work-runtime-ktx   no Worker, CoroutineWorker or WorkManager
                                   reference, and no provider or initializer entry
                                   in AndroidManifest.xml
  okhttp3:okhttp-coroutines        redundant rather than unused: quartz-android
                                   1.14.0 depends on it and at 5.5.0, which was
                                   already upgrading this declaration's 5.4.0. It
                                   stays on the runtime classpath either way
  com.ionspin.kotlin:bignum        no com.ionspin import. Arrived in a10dc1a with
                                   lightning-mobile support, not with
                                   secp256k1-frost-kmp, though frost was the last
                                   thing in the tree that used bignum at all
  no.synth:kmp-zip                 no no.synth import
  no.synth:kmp-zip-okio

The catalog entries and the pagingCommon, workRuntimeKtx and okhttp version refs
go with them, since each was referenced exactly once and nothing else in this
build points at them. lightning-kmp-app also declares work-runtime-ktx, but reads
it from its own catalog, so it is unaffected.

Deliberately kept:

app.cash.sqldelight looks unused by the same test -- no .kt file under
composeApp/src imports it -- but composeApp holds .sq schemas for three databases
(ChannelsDatabase, PaymentsDatabase, AppDatabase) and the generated sources import
it in 29 files. The runtime, the coroutines extensions and the platform drivers
all stay.

compose.desktop.currentOs and kotlinx-coroutines-swing in jvmMain are untouched,
but worth flagging: the `jvm()` target is commented out, so that source set and the
files under composeApp/src/jvmMain are not built. Whether they are dead or waiting
for the desktop target to come back is a decision about the target, not about a
dependency, so this commit leaves both alone.

Commented-out declarations elsewhere in the file -- room vs room3, kspIosX64,
kspJvm -- are left as the migration markers they are.

`:composeApp:assembleDebug` passes and the APK still packages libsecp256k1-jni.so
for all four ABIs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:26:24 +02:00
Kgothatso Ngako
fa3c00a8ff build: drop the secp256k1-frost-kmp submodule
The previous commit moved the ChillDKG ritual onto bitcoin-kmp's native
implementation, which was the only thing in this app using this library. Nothing
imports `ac.cord.auxiliary.frost` or `ac.cord.auxiliary.cryptography` any more, so
the submodule, its composite build and the dependency on it all go.

Removed:
  - the `secp256k1-frost-kmp` submodule (deinit, git rm, and .git/modules cleared)
  - its entry in .gitmodules
  - `includeBuild("secp256k1-frost-kmp")` in settings.gradle.kts, with the
    surrounding comment made singular now that one composite build remains
  - `implementation("ac.cord.auxiliary:library:1.0.0")` in composeApp

Note that `ac/cord/auxiliary/compose/**` under composeApp/src/jvmMain is this
app's own code in a similarly-named package, unrelated to the library and
untouched.

Two commits made on the submodule to keep it building inside this composite --
gating its ios targets on macos, and moving it to AGP 9.4.0 -- existed only in
this repository's .git/modules copy and are destroyed by the removal. They were
exported as patches first. Neither is a loss worth chasing: both existed purely to
make that project cooperate with a composite build that no longer includes it.

The library is not gone from the world, only from this build. It still holds a
FROST signer, which is the obvious next need now that the ritual produces
threshold key material. But bitcoin-kmp ships `fr.acinq.bitcoin.crypto.frost.Frost`
against the same natives this app now packages, so the signer will almost
certainly come from there rather than from a second copy of secp256k1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:26:03 +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
eee8fa0fd8 build: package the android secp256k1 natives, with ChillDKG in them
The android app had no android secp256k1 natives at all, and had not had any for
as long as lightning-kmp-core has been a dependency. `dependencyInsight` on
debugRuntimeClasspath resolved every secp256k1 coordinate to
`:secp256k1-kmp:jni:jvm:{darwin,linux,mingw}` -- desktop .so/.dylib/.dll files,
loaded by extracting them from a jar, which cannot work on a device.

The cause is a chain of individually reasonable decisions. lightning-kmp-core
publishes no android variant, so an android consumer resolves it to the jvm one;
the jvm variant asks for `secp256k1-kmp-jni-jvm`; and nothing anywhere asks for
`secp256k1-kmp-jni-android`. lightning-kmp-app names it only in androidDeviceTest,
so consumers of the published library do not get it. It has to be named here.

Naming it alone would not have been enough. bitcoin-kmp's settings.gradle.kts
substituted five of secp256k1-kmp's coordinates to the fork's projects but not
-jni-android, so it would have resolved from Maven Central to stock 0.24.0 --
built from upstream libsecp256k1, with no ChillDKG module. Because the kotlin API
comes from the substituted root project, that combination type-checks and links
and then fails with UnsatisfiedLinkError at the first native call. The rule is
added in the submodule commits this carries.

Submodule commits carried here:

  lightning-kmp-app  ce1ed01 -> 6434282  build: carry the jni-android substitution
                                         down from bitcoin-kmp
    experimental/lightning-kmp  77be7b78 -> 5103b79e
      experimental/bitcoin-kmp  196a479 -> 65c4aab  build: substitute
                                         secp256k1-kmp-jni-android to the
                                         included build too

Verified through the artifact rather than the graph: composeApp-debug.apk now
carries lib/{arm64-v8a,armeabi-v7a,x86,x86_64}/libsecp256k1-jni.so, and `nm -D` on
the arm64 one exports all twelve Java_..._chilldkg_... JNI entry points --
hostpubkey_gen, params_hash, participant_step1/step2/finalize,
coordinator_step1/finalize, and the recover and investigate calls.

The three builds in the chain sit on `build/substitute-jni-android` branches
(lightning-kmp-app on `build/agp-9.4.0`, which now carries two commits) and none
are pushed, so a fresh clone cannot resolve these pointers yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:25:23 +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
4e939c4570 build: bump lightning-kmp-app to bbba08b, building lightning-kmp from source
lightning-kmp-app moves 6745d88 -> bbba08b, four commits:

  57353cf Minor updates
  9f3cd72 Build lightning-kmp from the experimental submodule via a composite build
  f22c30a Follow the submodule chain onto Gradle 9.7.1
  bbba08b Declare the ios targets only on a mac, so the IDE can import the project

9f3cd72 is the substantive one. lightning-kmp no longer comes from maven central: the
submodule now carries its own experimental/lightning-kmp submodule on branch `threshold`
-- the branch with the FROST/prefractal signers -- and substitutes
fr.acinq.lightning:lightning-kmp-core for that build's project. That build includes
bitcoin-kmp, which includes secp256k1-kmp, which compiles the C library from a secp256k1-zkp
fork. So this repo's build tree is now four levels deep and compiles native code.

Cloning therefore needs `git submodule update --init --recursive`, and because each included
build resolves the android SDK from its own local.properties rather than inheriting the
root's, the three new nested builds each need a (gitignored) local.properties with sdk.dir.

57353cf changed two signatures that MantraApplication implements -- LightningApplication
gained getApplicationContext(), and BusinessManager.initialize now takes the application
rather than a Context. Neither needed a source change: MantraApplication extends
android.app.Application, which already supplies getApplicationContext(), and it was already
passing `this`, which satisfies the narrowed parameter type.

The rest of this commit is what the update forces on the outer build.

gradle-wrapper.properties, 9.3.1 -> 9.7.1: f22c30a moved every build in the chain onto
9.7.1. An included build does not use its own wrapper -- the root build's gradle version runs
the whole tree -- so this repo has to follow for the chain to build at all.

gradle.properties, configuration cache off: secp256k1-kmp's `:jni:generateHeaders` and
`:native:buildSecp256k1<target>` both hold gradle script object references and cannot be
serialized. The configuration cache covers a whole build tree and has no per-build opt-out,
so an included build's incompatibility is this build's problem. The comment records how to
undo this once those tasks are fixed upstream.

composeApp/build.gradle.kts, ios targets gated on the host: secp256k1-kmp declares a
libsecp256k1 cinterop, which makes gradle switch off klib cross compilation for apple
targets. On linux nothing in the tree then offers an ios variant of
fr.acinq.phoenix:lightning-kmp-app, and the ios compilations failed with "No matching variant
of project ':lightning-kmp-app:library'" -- not a warning, a build failure. The gate covers
the target declarations, the iosMain dependencies (the source set only exists when the
targets do) and the kspIos* configurations (likewise). This mirrors bbba08b, which applied
the same gate inside the submodule for the same reason.

secp256k1-frost-kmp d3b294d applies that gate there too. Substitution rules apply across a
whole build tree, so that project's own lightning-kmp-core coordinate started resolving to
the source project without it asking, and every apple source set stopped resolving. The
android build masked it -- ios compilations are not in its task graph -- but
kmpPartiallyResolvedDependenciesChecker reported it and compileAppleMainKotlinMetadata failed
outright, which would have broken IDE import.

Note that `:composeApp:compileCommonMainKotlinMetadata` no longer exists on a linux host.
With ios gated off, androidTarget is the only declared target (jvm() is still commented out),
and KMP does not generate a commonMain metadata compilation for a single-target project.
`:composeApp:compileDebugKotlinAndroid` is the check now; it passes clean, with none of the
resolution errors above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:50:59 +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
539babbfce build: consume secp256k1-frost-kmp and lightning-kmp-app as composite-build submodules
Add both libraries as git submodules and resolve them through Gradle
composite builds instead of remote publications, so local changes to
either library are picked up by the app build directly.

Submodules:
* secp256k1-frost-kmp (github.com/ac-cord-ac/secp256k1-frost-kmp) at
  2478403 -- BIP-327 FROTH/FROST signing over secp256k1, KMP.
* lightning-kmp-app (github.com/kngako/lightning-kmp-app) at 6745d88 --
  phoenix business logic on top of lightning-kmp-core. Both submodules
  carry a local commit aligning them with this build's AGP 9.1.1 /
  Kotlin 2.4.10 (AGP version must match across a composite build or the
  android variants fail attribute matching).

settings.gradle.kts:
* includeBuild() both submodule checkouts.
* lightning-kmp-app's project stays named ':library' (compose-resources
  derives its Res package from the project name), so an explicit
  dependencySubstitution maps the coordinate the app declares,
  fr.acinq.phoenix:lightning-kmp-app, onto that project.
* Drop the jitpack.io repository: it only served com.github.kngako,
  which is no longer consumed as a binary.

composeApp/build.gradle.kts:
* commonMain gains ac.cord.auxiliary:library:1.0.0 (secp256k1-frost-kmp,
  substituted by the composite build).
* commonMain replaces com.github.kngako.lightning-kmp-app:library (via
  JitPack) with fr.acinq.phoenix:lightning-kmp-app:1.0.0 (substituted by
  the composite build). lightning-kmp-core still comes from Maven
  Central.
* Remove the RestoreJitPackClassifier component-metadata rule and the
  javax.inject import: it only existed to repair the artifact
  classifiers JitPack drops from the apple metadata variants, which no
  longer applies.

gradle/libs.versions.toml:
* Remove the now-unused lightningKmpApp version and the
  com.github.kngako lightning-kmp-app catalog entry.

Verified:
  ./gradlew :composeApp:compileCommonMainKotlinMetadata             :composeApp:compileDebugKotlinAndroid
2026-08-15 17:07:31 +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
b17901973c Add lightning-kmp-app as a commonMain dependency via JitPack
The shared lightning/phoenix logic now lives in kngako/lightning-kmp-app,
which publishes a single `:library` module. JitPack serves a repository's
submodules under <GROUP>.<ARTIFACT>, hence the
com.github.kngako.lightning-kmp-app:library coordinate. The repository is
content-filtered to com.github.kngako so it is not consulted for anything
else -- an unfiltered jitpack.io entry gets asked about every dependency
that misses in mavenCentral, and each miss is a remote round trip.

The version is a commit rather than master-SNAPSHOT. For a -SNAPSHOT
version JitPack advertises a unique-snapshot maven-metadata.xml
(timestamp=<sha>, buildNumber=1) while serving the files under their
literal -SNAPSHOT names, so gradle derives library-<target>-master-<sha>-1
and gets a 404 on every artifact. Pinning a commit sidesteps the snapshot
machinery entirely and is reproducible; it needs bumping when the fork
moves.

That leaves the classifier. JitPack rewrites the version inside a
published .module file and drops the classifier while doing so, so both
the sources and the host-specific metadata variants of each apple target
come back naming library-<target>-<ver>.jar -- a file that does not
exist, next to the -sources.jar and -metadata.jar that do. The klib and
the aar are named without a classifier and so survive the rewrite, which
is why the android compilation resolves this dependency perfectly well
and only the metadata compilations fail. Every shared ios source set
resolves through those, so a component metadata rule puts the -metadata
classifier back. It is scoped to the two apple modules and to their
metadata variant by name, so it cannot disturb the klib artifacts.

Verified: :composeApp:compileCommonMainKotlinMetadata and
:composeApp:compileDebugKotlinAndroid both pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 18:46:00 +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