296011dcd56b6628eacbfd86b0942d985ef7a998
490 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
296011dcd5 |
feat: give a queued message somewhere to say who it is private to
Two nullable columns and the v5 migration that adds them, ahead of the code that fills them, so the schema lands on its own and can be reverted on its own. MarmotInnerEvent.directMessageRecipientPublicKey is the outbound signal. The notary reads a queued row and has no other way to know a message is meant for one member rather than the room -- the plaintext is identical either way -- so this is what routes it into the gift wrap path. Inbound rows leave it null on purpose: the recipient is on the wrap's `p` tag, which is where every member reads it from, so a second copy on the row would be a second thing that can disagree. ChatMessage.directMessageRecipientPublicKey is what the transcript reads. Both lines a direct message can produce need it -- the one its two parties see, and the "sent a private message to Bob" line everybody else gets -- and holding it on the row keeps the view model off a join for a fact it already has to render. MarmotInnerEvent hand-writes equals and hashCode over every field, so both are extended too. A field missing from those is not a compile error and not a test failure; it is two rows that differ comparing equal, which surfaces much later as an upsert that does nothing. Room generates the migration -- verified as two ADD COLUMNs with no table rebuild, so nothing is copied and nothing can be dropped: ALTER TABLE `ChatMessage` ADD COLUMN `directMessageRecipientPublicKey` TEXT DEFAULT NULL ALTER TABLE `MarmotInnerEvent` ADD COLUMN `directMessageRecipientPublicKey` TEXT DEFAULT NULL Rows written before this come back null, which reads as "not a direct message" -- the only answer that is true of all of them. v5 is an AutoMigration entry rather than a hand-written Migration like MIGRATION_3_4 next to it, because that one rewrote data without changing shape and this one changes shape without touching data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
79e99ae702 |
feat: build the envelope a direct message travels in
A one-to-one message inside a Marmot group is a stock NIP-59 gift wrap carried as the MLS application payload: a throwaway-keyed kind:1059 around a sender-signed kind:13 seal around the kind:14 rumor holding the words. Every member decrypts the MLS layer and sees the wrap; only the recipient can open it. See docs/marmot-direct-messages.md. This is the crypto on its own, with no database and no MLS state, because the outbound path (the notary) and the inbound path (the kind switch in ChatMessage) both need it and neither can be unit-tested -- there is no sqlite driver on the JVM test classpath. Extracting it first is what makes the ten tests here possible; real secp256k1 does load under testDebugUnitTest, so none of this is mocked. Three choices worth stating, all of them consequences of the wrap using a throwaway key rather than the sender's own: Nothing in the wrap names the sender. GiftWrapEvent.create mints and discards its own random key, so who sent a message comes from the MLS frame around it -- authenticated to a leaf, and unforgeable -- rather than from a self-asserted pubkey field. The seal inside is the only layer the sender signs, which is what the inbound path will bind to the MLS sender identity before it renders a word. The sender cannot reopen their own message. The throwaway key is gone at send time and nothing reconstructs it. `the sender cannot reopen their own message` asserts that rather than leaving it to be discovered, because the obvious fix -- persisting the throwaway private key -- would be strictly worse than the identity-keyed wrap this was chosen over, and would reintroduce the attribution the throwaway key exists to remove. No layer is fuzzed. NIP-59 randomises the wrap and the seal by up to two days to frustrate correlation at a relay, and both GiftWrapEvent.create and SealedRumorEvent.create default to it. There is no relay at this layer and the kind:445 already carries the true time, so fuzzing would only scatter the "sent a private message" line up to two days out of position in every other member's transcript. open() returns null rather than throwing on every way a wrap can fail to open -- somebody else's message, a malformed payload, a layer that is not the kind it claims. Its caller is midway through processing a kind:445 that may carry a perfectly good message for somebody else, and an exception would abandon all of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
6a5b6cd6cb | Add ephemeral Relays.kt | ||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
3576c00ce2 |
feat: put every ritual message in the group's chat, naming who sent it
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
e1b79ad842 | Selection container on text | ||
|
|
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>
|
||
|
|
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
|
||
|
|
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
|
||
|
|
ea8b2b276a | Remove hack because quarz didn't persist the secretTree in an older version. | ||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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 |
||
|
|
086b2f4e70 | Update dependencies | ||
|
|
2f06485ec5 | Update to make use of lightning-kmp-appp | ||
|
|
e9510c4ea5 | Remove phoenix code | ||
|
|
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> |
||
|
|
7d893c7a79 | Bug fix for when new member is added. | ||
|
|
62233f0fd8 | Try to broadcast in specific order | ||
|
|
180b8c5aa5 | Remove scaffolding | ||
|
|
6e13938478 | Add and broadcast translation chunks version | ||
|
|
02b2c9a4f8 |
Add and broadcast translation artifact version
We might not want to do translation chunk scaffolding |
||
|
|
8de05742d0 | Add and broadcast chunked chapters | ||
|
|
83f777a758 | Add chat messages for mantra logic | ||
|
|
eefcbaa157 | Bug fix for inner events. |