No FROST signing message has ever reached another participant. The proposal
was built, MLS-encrypted, wrapped under the exporter secret, signed as a
kind:445, written to NostrEvent and MarmotGroupEvent, and its queue row
marked processed -- and then never handed to a relay, by a branch that was
never about delivery at all.
**The gate.** The tail of MarmotOutboundDao.encryptAndSendMarmotInnerEvent
looked up the transcript row for the queued rumor and did everything else
inside it:
val chatMessageOrNull = database.chatMessageDao()
.getChatMessagesByMarmotInnerEventId(marmotInnerEvent.id)
chatMessageOrNull?.let { chatMessage ->
... relation, marmotGroupEventId ...
val ids = database.broadcastNostrEventRequestDao().insert(...)
}
The BroadcastNostrEventRequest rows are the only thing that puts a kind:445
on a relay -- observeBroadcastNostrEventRequestsByStatus("pending") is what
the broadcaster watches, and nothing else inserts them for this path. So the
question "does the chat have a line for this?" was silently answering the
question "should the group receive this?".
**Why FROST always lost.** A signing message has no ChatMessage by design.
FrostSigningManager.broadcast queues the rumor alone, and announce() writes
its milestone lines with marmotInnerEventId = null on purpose: each device
writes its own transcript from the messages it has already received, so the
lines cost no traffic and cannot disagree with the session they describe.
The inbound half states the same intent from the other side --
ChatMessage.applyInnerEvent returns null for every FrostSigningEvents kind,
because a row there would be a second, worse account of what the manager
already narrates.
That is every kind in the family, not just the proposal: nonces, the signer
set, partial signatures, the finished signature and the failure notice all
go through the same broadcast(). A session could not have completed even if
a proposal had somehow arrived.
**GroupKeyStateManager.announce had it too.** Same shape, same silence: a
room's kind:30326 announcement of which key it signs with was queued,
encrypted and dropped. a909108 added it so members would stop rederiving;
no member has ever received one.
**Why the neighbours worked, and hid it.** The DKG rides NIP-17 gift wraps
through a different path entirely, so a room could finish a ceremony, hold a
real shared key, and report canSign() == true with the signing transport
dead beneath it. nip30303 submissions work because MantraDao.sendMarmotInnerEvent
pairs every queued rumor with a ChatMessage carrying its id -- not as a
delivery mechanism, just because a submission is also something a member did.
FROST was the first traffic to use the group path without a chat line, which
is why this reads as a FROST bug and is not one.
**Why it went unnoticed.** Nothing failed. The coordinator's own device is
fully convinced: proposeSigning writes the session, announceStarted puts a
line in the chat, advance() runs, and publishOwn records the coordinator's
own nonce and announces that step too. From the proposer's side a session
nobody else can see is indistinguishable from one waiting on slow peers.
Unlike 65e4a3a, the queue did not block. marmotGroupEventId is set before
the transcript lookup, so the row left the queue cleanly and the next one
was picked up. Every message was lost individually, in silence, with no
backlog to notice.
**The fix.** The broadcast insert is hoisted out of the branch, and the
decision it was tangled with is lifted into MarmotDelivery.plan: given a
group event, a relay list, and a chat message or null, what has to be
written. A group event is sent because it was queued; a chat line is linked
because a member said something. The DAO now computes that plan and executes
it, with the insert as a plain unconditional statement ahead of the
bookkeeping that legitimately does depend on there being a line.
The extraction is not decoration. encryptAndSendMarmotInnerEvent is
Room-backed and cannot be stood up in a unit test, which is exactly how the
gate survived; separating the decision from the filing of it is the same
move MarmotDirectMessage.classify exists for, and for the same stated
reason.
**Tests.** MarmotDeliveryTest, six of them. The two that matter are "a
signing proposal goes out, though nothing in the chat points at it" and "the
send does not depend on the transcript", which asserts the broadcast list is
identical with and without a chat message. The rest pin the supporting
facts: one request per relay naming the event, every request written pending
because that is the only status the broadcaster looks at, the linkage that
does depend on a chat line, and an empty relay list as the sole legitimate
way to produce an empty broadcast list -- so that an empty list always reads
as "nowhere to send it" and never as "nothing to send".
**Not covered, deliberately.** These pin the decision, not the call site. Re-
nesting the insert inside chatMessageOrNull?.let would leave MarmotDelivery
correct and every test passing. Closing that needs the DAO itself under
test: BundledSQLiteDriver is on the classpath and getInMemoryDatabaseBuilder
exists, but its android actual wants a real Context, testDebugUnitTest is
plain JVM, and there is no androidUnitTest source set or Robolectric. That
is its own change, not one to smuggle in here.
Verified: :composeApp:compileDebugKotlinAndroid succeeds; 160 tests pass,
154 before these six. The inbound half was read rather than assumed --
NostrDao dispatches FrostSigningEvents kinds to processSigningPayload,
inbound rumors are stored with marmotGroupEventId set so they cannot re-enter
the outbound queue, and the out-of-order replay path is intact. Outbound was
the only break. That two participants now actually see a proposal is
inference from the code, not an observation: it wants two devices.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This is a Kotlin Multiplatform project targeting Android, iOS, Desktop (JVM).
-
/composeApp is for code that will be shared across your Compose Multiplatform applications. It contains several subfolders:
- commonMain is for code that’s common for all targets.
- Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app, the iosMain folder would be the right place for such calls. Similarly, if you want to edit the Desktop (JVM) specific part, the jvmMain folder is the appropriate location.
-
/iosApp contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform, you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
Build and Run Android Application
To build and run the development version of the Android app, use the run configuration from the run widget in your IDE’s toolbar or build it directly from the terminal:
- on macOS/Linux
./gradlew :composeApp:assembleDebug - on Windows
.\gradlew.bat :composeApp:assembleDebug
Build and Run Desktop (JVM) Application
To build and run the development version of the desktop app, use the run configuration from the run widget in your IDE’s toolbar or run it directly from the terminal:
- on macOS/Linux
./gradlew :composeApp:run - on Windows
.\gradlew.bat :composeApp:run
Build and Run iOS Application
To build and run the development version of the iOS app, use the run configuration from the run widget in your IDE’s toolbar or open the /iosApp directory in Xcode and run it from there.
Learn more about Kotlin Multiplatform…