Commit Graph

470 Commits

Author SHA1 Message Date
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
8930d292bf build: bump secp256k1-frost-kmp to e67fe17 for ChillDKG
Fast-forwards the submodule 8 commits, 2478403 -> e67fe17. The pinned
commit is an ancestor of upstream master -- the local "align with AGP
9.1.1 / Kotlin 2.4.10" commit had already been pushed there -- so this
is a clean advance with nothing to rebase or re-apply.

What it brings, all of it PR #1 (ac-cord-ac/latest-greatest):

  6775d51  Fix stale trusted-dealer path in KNOWN_ISSUES.md
  880d57a  Document the ChillDKG module in the READMEs
  ed5f7f4  Add ChillDKG test vectors and ported test suite
  4345dc0  Add ChillDKG port: public API (chilldkg.py)
  94686be  Add ChillDKG port: SimplPedPop and EncPedPop sub-protocols
  cf13e22  Add ChillDKG port: crypto primitives, error hierarchy, and VSS
  0b7223f  Move trusted-dealer keygen into the dkg package

+7,817 lines, almost all of it a Kotlin port of ChillDKG -- distributed
key generation for FROST, ported from BlockstreamResearch/bip-frost-dkg
@ a918968 (BIP draft 0.3.0-dev) -- plus its sub-protocols (SimplPedPop,
EncPedPop), Feldman VSS, the BIP DKG tagged-hash primitives, and all 10
official vector files exercised by 249 vector cases.

This is the piece the group work has been blocked on. A threshold that
actually means something needs a FROST key no single member holds, and
until now the library only offered FrostTrustedDealer, which requires
one party to know the whole secret -- fine for tests, wrong for a group
whose entire premise is that nobody is privileged. ChillDKG generates
that key among the participants with no dealer at all.

One breaking change rides along: frost/trusted_dealer_keygen/ moved to
frost/dkg/trusted/. It costs nothing here -- the app declares the
library as a dependency but does not yet call any of its API, so there
is nothing to update. (The ac.cord.auxiliary.compose packages under
jvmMain are the app's own leftover naming, unrelated to the library.)

Verified against the bumped submodule, with the composite build
recompiling the library from source:
  ./gradlew :composeApp:compileCommonMainKotlinMetadata
  ./gradlew :composeApp:compileDebugKotlinAndroid

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:14:25 +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
f21b13da07 Update quarts 2026-08-29 16:22:56 +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
Kgothatso Ngako
6e13938478 Add and broadcast translation chunks version 2026-07-28 09:29:15 +02:00
Kgothatso Ngako
02b2c9a4f8 Add and broadcast translation artifact version
We might not want to do translation chunk scaffolding
2026-07-28 09:21:16 +02:00
Kgothatso Ngako
8de05742d0 Add and broadcast chunked chapters 2026-07-28 09:10:20 +02:00
Kgothatso Ngako
83f777a758 Add chat messages for mantra logic 2026-07-28 01:59:28 +02:00
Kgothatso Ngako
eefcbaa157 Bug fix for inner events. 2026-07-28 01:39:32 +02:00
Kgothatso Ngako
33c4d1000f Send artifacts inner events 2026-07-28 00:52:21 +02:00
Kgothatso Ngako
0c65f61ea2 Fix chat initiation between profiles created in the app
A profile created in the app could never start a chat with another
profile created in the app: the peer always looked like it had no
metadata, no DM relay list and no MLS key package.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:46:16 +02:00
Kgothatso Ngako
c41c7083f5 Fix for the correct thread again 2026-07-26 02:42:31 +02:00
Kgothatso Ngako
b0c523c0f0 Merge branch 'claude/admiring-mahavira-a862d1' into artifacts 2026-07-26 02:41:29 +02:00
Kgothatso Ngako
dc23295d6e Rename TranslationDetail screen family to TranslationArtifactVersionDetail
Rename the screen and its ViewModel, UI state, and route to reflect that
they describe a MantraTranslationArtifactVersion:
- TranslationDetailScreen -> TranslationArtifactVersionDetailScreen
- TranslationDetailViewModel -> TranslationArtifactVersionDetailViewModel
- TranslationDetailUIState -> TranslationArtifactVersionDetailUIState
- TranslationDetailRoute -> TranslationArtifactVersionDetailRoute

Files moved with git mv to preserve history; all references (nav host,
artifact detail screen) updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:40:42 +02:00
Kgothatso Ngako
90c6194071 Merge branch 'claude/admiring-mahavira-a862d1' into artifacts 2026-07-26 02:30:46 +02:00
Kgothatso Ngako
c494cf9e11 Add per-chunk translation editor
The translation column of the chapter table is now a TextButton (with a
ChevronRight) per chunk; tapping it opens an editor for that chunk.

- TranslateChunkScreen (route + screen/ViewModel/UIState) shows the full
  original chunk and a text input prefilled with any existing translation.
  Saving persists the translation and returns to a freshly-loaded chapter
  table (popUpTo<TranslationChapterRoute>) so the update shows.
- MantraRepository.saveTranslationChunk builds a MantraTranslationChunk from
  the entered text (content-hash id, index mirrored from the source chunk),
  upserts it plus its MarmotInnerEvent rumor, and replaces any prior
  translation chunk for the same source chunk (deleting the stale row and its
  rumor) so there is exactly one per source chunk. New DAO queries getChunkById,
  MantraTranslationChunkDao.deleteById, MarmotInnerEventDao.deleteById, and
  repository getChunk.
- TranslationChapterScreen renders the translation cell as the button and
  navigates to TranslateChunkRoute with the source chunkId.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:29:58 +02:00
Kgothatso Ngako
72f0233bed Merge branch 'claude/admiring-mahavira-a862d1' into artifacts 2026-07-26 02:19:56 +02:00
Kgothatso Ngako
2170f434dc Add TranslationChapter screen with side-by-side chunks
Chapter cards in the translation detail now open a two-column chapter
translation screen.

- The screen loads the source MantraChapter's chunks paired with their
  MantraTranslationChunks (by chunkId). Column one is the original chunks,
  column two the translated chunks; the header row shows the original dialect
  name and the translation dialect name. When a chunk has no translation
  (missing or blank text), the original text is shown greyed out as a
  placeholder in the second column.
- The ViewModel resolves the original dialect by walking chapter -> version
  -> artifact -> dialect and the translation dialect via the translation
  version. New DAO queries getArtifactVersionById and getTranslationChapterById
  plus repository accessors getArtifactVersion/getTranslationChapter/getDialect.
- New TranslationChapterRoute + screen/ViewModel/UIState; TranslationDetail
  chapter cards navigate to it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:19:30 +02:00
Kgothatso Ngako
05e200319a Merge branch 'claude/admiring-mahavira-a862d1' into artifacts 2026-07-26 02:06:06 +02:00
Kgothatso Ngako
e2fe415a1a Add MantraTranslationArtifactVersion detail screen
Translation cards in the artifact detail's Translations list are now
clickable, opening a translation detail screen.

- New TranslationDetailRoute + TranslationDetailScreen/ViewModel/UIState.
  The screen shows the translation's details (name, dialect, visibility,
  license, source version, author, created) and its chapters ordered by
  index, each with translation progress (translated/total chunks, where a
  chunk counts as translated once its text is non-empty).
- DAO queries getTranslationById, translation chapters by
  translationArtifactVersionId, and translation chunks by
  translationChapterId (validated by Room), exposed via MantraRepository
  (impl + NO_OP).
- Extract the DetailRow composable (was private to ArtifactDetailScreen)
  into a shared widgets/DetailRow.kt used by both detail screens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:05:55 +02:00
Kgothatso Ngako
f097d71237 Name a translation after its dialect
addTranslation set the MantraTranslationArtifactVersion name to the source
artifact's name; use the selected dialect's name instead (visibility and
license still inherit from the artifact). Add a getDialectById query to look
the dialect up by id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:59:32 +02:00
Kgothatso Ngako
de75823426 Merge branch 'claude/admiring-mahavira-a862d1' into artifacts 2026-07-26 01:53:02 +02:00
Kgothatso Ngako
99f99fa959 Add the add-translation workflow from the artifact detail screen
An Add Translation button in the artifact detail's Translations section
opens a workflow that requires selecting or creating a dialect (reusing the
chip picker + create-new-dialect fields), then scaffolds the translation.

- MantraRepository.addTranslation creates a MantraTranslationArtifactVersion
  for the artifact's latest version and the chosen dialectId (inheriting the
  source artifact's name/visibility/license), then mirrors the source
  structure: a MantraTranslationChapter per chapter and a
  MantraTranslationChunk (empty text scaffold) per chunk. Each created entity
  also gets a MarmotInnerEvent rumor (kinds 30306/30308/30309). When a new
  dialect is requested the ViewModel creates it first via addDialect.
- The three translation events' build methods now take real fields (fixing
  their phantom generics); toXEvent tag order matches build and
  fromXEventTemplate companions are added so the event ids round-trip.
- New AddTranslationScreen (route + ViewModel + UIState). On success it lands
  on a freshly-loaded artifact detail via popUpTo<ArtifactDetailRoute>.

No MantraTranslation rows / translation text are created here — that is the
per-chunk authoring step, intentionally left out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:52:15 +02:00
Kgothatso Ngako
6b07bcd78f Apply correct thread 2026-07-26 01:39:15 +02:00
Kgothatso Ngako
b2664bf7a5 Add ChapterDetailScreen showing chapter text and chunks
Tapping a chapter in the artifact detail now opens a chapter detail screen.

- New ChapterDetailRoute + ChapterDetailScreen/ViewModel/UIState. The screen
  shows the chapter's index and word/character counts, its original markdown
  text (rendered as plain text — no markdown renderer available), and the
  chapter's paragraph chunks (index, text, counts) with an empty state.
- DAO queries getChapterById and getChunksByChapterId (validated by Room),
  exposed via MantraRepository.getChapter / getChunksForChapter (impl + NO_OP).
- ArtifactDetailScreen chapter cards are now clickable, navigating to
  ChapterDetailRoute; MantraNavHost registers the route.

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