)
}
+
+export default function QuorumChat(props: { isVisible?: () => boolean }) {
+ const me = useActivePubkey()
+ const quorum = createMemo(() => activeQuorum())
+ // The thread may be tagged with the invite id (while pending) and/or the quorum pubkey
+ // (once established); query both so the conversation is continuous across that transition.
+ const threadIds = createMemo(() => {
+ const q = quorum()
+ if (!q) { return [] }
+ return [q.inviteId, q.quorumPubkey].filter((x): x is string => Boolean(x))
+ })
+ const messages = createMemo(() => chatMessages(threadIds()))
+
+ const [draft, setDraft] = createSignal("")
+ const [sending, setSending] = createSignal(false)
+ let listEl: HTMLDivElement | undefined
+
+ // Keep the message list pinned to the bottom as messages arrive.
+ createEffect(() => {
+ messages()
+ if (listEl) { listEl.scrollTop = listEl.scrollHeight }
+ })
+
+ // Mark the chat read while it is actually on screen (re-runs as messages arrive). Both
+ // the desktop side panel and the mobile chat tab render a QuorumChat, and both stay
+ // mounted (the other is just CSS-hidden), so `isVisible` tells this instance whether it
+ // is the visible one — otherwise the hidden desktop panel would mark chat read on mobile
+ // and the chat tab badge would never appear.
+ createEffect(() => {
+ const q = quorum()
+ if (!q) { return }
+ const visible = props.isVisible ? props.isVisible() : true
+ messages()
+ if (visible) { markTabViewed(q.inviteId, "chat") }
+ })
+
+ async function send() {
+ const q = quorum()
+ const text = draft().trim()
+ if (!q || !text || sending()) { return }
+ setSending(true)
+ try {
+ // Send under the established pubkey if it exists, otherwise the invite id.
+ const threadId = q.quorumPubkey ?? q.inviteId
+ await sendChatMessage(threadId, q.members.map(m => m.pubkey), text)
+ setDraft("")
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to send message")
+ } finally {
+ setSending(false)
+ }
+ }
+
+ function onKeyDown(e: KeyboardEvent) {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault()
+ send()
+ }
+ }
+
+ return (
+
+
+ Select a quorum to chat with its members.
+
+
+ }
+ >
+
+
+ 0}
+ fallback={
+
+
+ No messages yet. Say hello to your quorum.
+
+
+ }
+ >
+
+ {(event) => }
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/QuorumDetail.tsx b/src/components/QuorumDetail.tsx
index 94b6820..9fb12d3 100644
--- a/src/components/QuorumDetail.tsx
+++ b/src/components/QuorumDetail.tsx
@@ -1,6 +1,9 @@
import { Show, Switch, Match } from "solid-js"
import { activeQuorum, view, setTab } from "../store"
-import { useProfileDisplay } from "../hooks"
+import { useProfile } from "../hooks"
+import { displayProfile } from "@welshman/util"
+import { tabHasActivity } from "../engine/notifications"
+import { isDesktop } from "../lib/media"
import QuorumLog from "./tabs/QuorumLog"
import QuorumMembers from "./tabs/QuorumMembers"
import QuorumChat from "./QuorumChat"
@@ -21,8 +24,14 @@ const badgeClass: Record = {
}
export default function QuorumDetail(props: Props) {
- const quorumName = useProfileDisplay(() => activeQuorum()?.quorumPubkey ?? "")
- const title = () => (activeQuorum()?.quorumPubkey ? quorumName() : "New quorum")
+ const profile = useProfile(() => activeQuorum()?.quorumPubkey ?? "")
+ // Use the quorum's profile name when it has one; fall back to "Quorum" (or "New quorum"
+ // while the key doesn't exist yet).
+ const headerName = () => {
+ const q = activeQuorum()
+ if (!q?.quorumPubkey) { return "New quorum" }
+ return displayProfile(profile(), "Quorum")
+ }
const complete = () => Boolean(activeQuorum()?.complete)
return (
-
+
-
- Quorum
+
+ {headerName()}
-
+
{activeQuorum()!.statusLabel}
)
}
diff --git a/src/components/tabs/QuorumMembers.tsx b/src/components/tabs/QuorumMembers.tsx
index f8fda5d..fb86c4a 100644
--- a/src/components/tabs/QuorumMembers.tsx
+++ b/src/components/tabs/QuorumMembers.tsx
@@ -1,6 +1,7 @@
-import { createSignal, Show, For } from "solid-js"
+import { createSignal, createEffect, Show, For } from "solid-js"
import { activeQuorum } from "../../store"
import { useProfileDisplay } from "../../hooks"
+import { markTabViewed } from "../../engine/notifications"
import type { QuorumMember } from "../../protocol"
function MemberRow(props: { member: QuorumMember; copied: boolean; onCopy: () => void }) {
@@ -29,6 +30,12 @@ function MemberRow(props: { member: QuorumMember; copied: boolean; onCopy: () =>
export default function QuorumMembers() {
const [copiedIndex, setCopiedIndex] = createSignal(null)
+ // Mark the members tab read while it is on screen.
+ createEffect(() => {
+ const q = activeQuorum()
+ if (q) { markTabViewed(q.inviteId, "members") }
+ })
+
function copyPubkey(m: QuorumMember) {
navigator.clipboard.writeText(m.pubkey)
setCopiedIndex(m.index)
diff --git a/src/engine/chat.ts b/src/engine/chat.ts
new file mode 100644
index 0000000..66524b5
--- /dev/null
+++ b/src/engine/chat.ts
@@ -0,0 +1,88 @@
+import { createSignal } from "solid-js"
+import { Nip59, Nip01Signer } from "@welshman/signer"
+import {
+ pubkey,
+ signer,
+ repository,
+ publishThunk,
+ getMessagingRelayList,
+ loadMessagingRelayList,
+} from "@welshman/app"
+import { deriveEvents } from "@welshman/store"
+import { makeEvent, prep, getRelaysFromList, getTagValue } from "@welshman/util"
+import type { TrustedEvent, HashedEvent } from "@welshman/util"
+import { Router } from "@welshman/router"
+
+// ── NIP-17 group chat among quorum members ─────────────────────────────────────
+// Members exchange kind-14 chat messages under their OWN pubkeys, gift-wrapped per
+// NIP-17 (kind 1059) to every member. This is ordinary NIP-17 — it does NOT depend on
+// the quorum's FROST key, so members can chat while a DKG is still pending: the member
+// set is known from the invite from the start. Messages carry NO ["t","b7ed"] topic tag
+// (they are not protocol events); they are tagged with the quorum thread id for grouping.
+
+const CHAT_KIND = 14
+
+function inboxRelays(recipient: string): string[] {
+ const relays = getRelaysFromList(getMessagingRelayList(recipient))
+ return relays.length ? relays : Router.get().ForPubkey(recipient).getUrls()
+}
+
+/** Gift-wrap a kind-14 rumor to one member (no b7ed topic) and publish to their inbox relays. */
+async function wrapTo(recipient: string, rumor: HashedEvent): Promise {
+ const $signer = signer.get()
+ if (!$signer) { throw new Error("Cannot send without a signer") }
+ await loadMessagingRelayList(recipient).catch(() => undefined)
+ const nip59 = new Nip59($signer, Nip01Signer.ephemeral())
+ const wrap = await nip59.wrap(recipient, rumor)
+ publishThunk({ event: wrap, relays: inboxRelays(recipient) })
+}
+
+/**
+ * Send a chat message to a quorum's members. Plain NIP-17 — works whether or not the
+ * quorum's key exists yet. `threadId` is the quorum's stable thread id (its pubkey once
+ * established, otherwise the invite id); `members` are the known member pubkeys (available
+ * from the invite even before the DKG completes). We store a local copy immediately and
+ * gift-wrap one copy to every member except ourselves.
+ */
+export async function sendChatMessage(threadId: string, members: string[], text: string): Promise {
+ const me = pubkey.get()
+ if (!me) { throw new Error("You must be logged in to send a message") }
+
+ const recipients = members.length ? members : [me]
+ const rumor = prep(makeEvent(CHAT_KIND, {
+ content: text,
+ tags: [["quorum", threadId], ...recipients.map(m => ["p", m])],
+ }), me)
+
+ // Optimistic local copy so our own message renders without a network round-trip.
+ repository.publish(rumor)
+
+ await Promise.all(
+ recipients
+ .filter(m => m !== me)
+ .map(m => wrapTo(m, rumor).catch(e => console.error("chat deliver failed", m, e))),
+ )
+}
+
+// ── Repository-derived chat signal ─────────────────────────────────────────────
+// One app-lifetime subscription over all kind-14 events (inbound unwrapped rumors +
+// our own local copies); chatMessages filters to the requested thread on read.
+const [allChat, setAllChat] = createSignal([])
+deriveEvents({ repository, filters: [{ kinds: [CHAT_KIND] }] }).subscribe(setAllChat)
+
+/**
+ * Kind-14 messages for a quorum, sorted ascending by created_at. Pass every id the thread
+ * may be tagged with — typically [inviteId, quorumPubkey] — so the conversation stays
+ * continuous across the moment the quorum's pubkey comes into existence (messages sent
+ * while pending are tagged with the invite id; later ones with the quorum pubkey).
+ */
+export function chatMessages(threadIds: string[]): TrustedEvent[] {
+ const ids = new Set(threadIds.filter(Boolean))
+ if (!ids.size) { return [] }
+ return allChat()
+ .filter(e => {
+ const q = getTagValue("quorum", e.tags)
+ return q !== undefined && ids.has(q)
+ })
+ .sort((a, b) => a.created_at - b.created_at)
+}
diff --git a/src/engine/delivery.ts b/src/engine/delivery.ts
new file mode 100644
index 0000000..98661eb
--- /dev/null
+++ b/src/engine/delivery.ts
@@ -0,0 +1,35 @@
+import { Nip59, Nip01Signer } from "@welshman/signer"
+import { signer, repository, publishThunk, getMessagingRelayList } from "@welshman/app"
+import { getRelaysFromList } from "@welshman/util"
+import type { HashedEvent } from "@welshman/util"
+import { Router } from "@welshman/router"
+
+const TOPIC = "b7ed"
+
+// TODO: PROTOCOL.md §Event Kinds requires at least 16 bits of proof-of-work (NIP-13)
+// on each kind 1059 gift wrap. PoW is out of scope for this pass; the ["t","b7ed"]
+// topic tag below IS required and is always present.
+
+function inboxRelays(pubkey: string): string[] {
+ const relays = getRelaysFromList(getMessagingRelayList(pubkey))
+ return relays.length ? relays : Router.get().ForPubkey(pubkey).getUrls()
+}
+
+/** Gift-wrap a rumor to one recipient (with the b7ed topic tag) and publish to their inbox relays. */
+export async function deliverTo(recipient: string, rumor: HashedEvent): Promise {
+ const $signer = signer.get()
+ if (!$signer) { throw new Error("Cannot send without a signer") }
+ const nip59 = new Nip59($signer, Nip01Signer.ephemeral())
+ const wrap = await nip59.wrap(recipient, rumor, [["t", TOPIC]])
+ publishThunk({ event: wrap, relays: inboxRelays(recipient) })
+}
+
+/**
+ * Broadcast one rumor to many recipients. Stores a local copy in the repository (so our own derived
+ * state reflects what we sent) unless storeLocal is false. Per-recipient payloads (round-2 shares) call
+ * deliverTo directly with storeLocal handled by the caller. Recipients should EXCLUDE self.
+ */
+export async function sendProtocolEvent(rumor: HashedEvent, recipients: string[], opts: { storeLocal?: boolean } = {}): Promise {
+ if (opts.storeLocal !== false) { repository.publish(rumor) }
+ await Promise.all(recipients.map(r => deliverTo(r, rumor).catch(e => console.error("deliver failed", r, e))))
+}
diff --git a/src/engine/dkg.ts b/src/engine/dkg.ts
new file mode 100644
index 0000000..9ae1890
--- /dev/null
+++ b/src/engine/dkg.ts
@@ -0,0 +1,517 @@
+import { createEffect, createSignal } from "solid-js"
+import { toast } from "solid-toast"
+import { pubkey, signer, loadMessagingRelayList } from "@welshman/app"
+import { getTagValue } from "@welshman/util"
+import type { TrustedEvent } from "@welshman/util"
+import {
+ assignIndices,
+ createInvite,
+ dkgRound1,
+ verifyRound1,
+ dkgRound2,
+ dkgFinalize,
+ dkgConfirm,
+ createDecline,
+} from "../protocol"
+import type { Hex, QuorumMember } from "../protocol"
+import { encryptPoly, decryptPoly } from "../models"
+import type { DkgSession, Round1Data } from "../models"
+import { sendProtocolEvent, deliverTo } from "./delivery"
+import { sendChatMessage } from "./chat"
+import {
+ inviteEvents,
+ dkgRound1Events,
+ dkgRound2Events,
+ dkgConfirmEvents,
+ declineEvents,
+ sessionId,
+ commitsOf,
+ proofOf,
+ shareOf,
+ membersOf,
+ thresholdOf,
+ quorumOf,
+ reasonOf,
+ latestByAuthor,
+} from "./events"
+import {
+ getProgress,
+ patchProgress,
+ saveQuorum,
+ sessionProgress,
+} from "./secrets"
+import { openQuorum, setDelivery } from "../store"
+
+// ── Active pubkey, bridged to a Solid signal ───────────────────────────────────
+// Mirrors the store.ts pattern: module-scope signals fed by welshman stores so the
+// derivation re-runs reactively as the session/identity changes.
+const [me, setMe] = createSignal("")
+pubkey.subscribe(pk => setMe(pk ?? ""))
+
+// Progress lives in localStorage (a svelte store), not a Solid signal, so changes to
+// it must explicitly retrigger derivation. Bumping this tick on every progress write
+// makes dkgSessions() recompute when a flag flips (e.g. after a guarded send).
+const [progressTick, setProgressTick] = createSignal(0)
+sessionProgress.subscribe(() => setProgressTick(n => n + 1))
+
+// ── Session-state derivation ───────────────────────────────────────────────────
+// A DKG session is a pure function of the 7050 invite, the 7051/7052/7053/7061 events
+// referencing it, and our persisted progress. It tolerates duplicate / out-of-order
+// events: dedupe is by-author (latest wins) and prerequisites are checked explicitly,
+// never by arrival order.
+
+function eventsForInvite(events: TrustedEvent[], inviteId: string): TrustedEvent[] {
+ return events.filter(e => sessionId(e) === inviteId)
+}
+
+function deriveDkgSessions(mine: string): DkgSession[] {
+ // Read every accessor up-front so this function tracks them reactively.
+ const invites = inviteEvents()
+ const r1Events = dkgRound1Events()
+ const r2Events = dkgRound2Events()
+ const confirmEvents = dkgConfirmEvents()
+ const declined = declineEvents()
+
+ const sessions: DkgSession[] = []
+
+ for (const inv of invites) {
+ const inviteId = inv.id
+ const members = membersOf(inv)
+
+ // Only build a session if I'm a participant (same predicate as displayedQuora).
+ if (inv.pubkey !== mine && !members.includes(mine)) { continue }
+
+ const threshold = thresholdOf(inv)
+ const indexed = assignIndices(members)
+ const ownIndex = indexed.find(m => m.pubkey === mine)?.index
+ const p = getProgress(inviteId)
+
+ // ── Round 1: one entry per author, keyed by pubkey; only members count. ──
+ const round1: Record = {}
+ for (const e of latestByAuthor(eventsForInvite(r1Events, inviteId)).values()) {
+ if (!members.includes(e.pubkey)) { continue }
+ const proof = proofOf(e)
+ if (!proof) { continue }
+ round1[e.pubkey] = { commitments: commitsOf(e), proof }
+ }
+
+ // ── Round 2: shares OTHERS sent to us, keyed by the SENDER's index. ──
+ // Every 7052 in the repository for our sessions was unwrapped from a gift wrap
+ // addressed to us, and our own outgoing 7052 are per-recipient (not stored
+ // locally), so this holds only peers' shares to us — exactly what finalize needs.
+ const round2: Record = {}
+ for (const e of latestByAuthor(eventsForInvite(r2Events, inviteId)).values()) {
+ const senderIndex = indexed.find(m => m.pubkey === e.pubkey)?.index
+ if (senderIndex === undefined) { continue }
+ const share = shareOf(e)
+ if (share === undefined) { continue }
+ round2[senderIndex] = share
+ }
+
+ // ── Declines: informational; only members count. ──
+ const declines: Record = {}
+ for (const e of latestByAuthor(eventsForInvite(declined, inviteId)).values()) {
+ if (!members.includes(e.pubkey)) { continue }
+ declines[e.pubkey] = reasonOf(e)
+ }
+
+ // ── Confirmations: count distinct matching authors (for completion + display). ──
+ const confirms = [...latestByAuthor(eventsForInvite(confirmEvents, inviteId)).values()]
+ .filter(e => members.includes(e.pubkey))
+ const confirmed = confirms.length
+
+ const aborted = Boolean(p.aborted) || isEquivocated(inviteId, members)
+ // Local completion is independent of peers' confirmations: we are "complete" the
+ // moment we finalized and saved our shard.
+ const complete = Boolean(p.finalized)
+
+ // ── Phase (a display hint; the coordinator branches on raw flags, not this). ──
+ let phase: DkgSession["phase"]
+ if (complete && confirmed >= members.length) {
+ phase = "complete"
+ } else if (p.sentConfirm || p.finalized || confirms.length > 0) {
+ phase = "confirming"
+ } else if (p.sentRound2) {
+ phase = "round2"
+ } else {
+ phase = "round1"
+ }
+
+ sessions.push({
+ inviteId,
+ members,
+ threshold,
+ phase,
+ myCommitments: round1[mine]?.commitments,
+ myEncryptedPoly: p.encryptedPoly,
+ round1,
+ round2,
+ declines,
+ aborted,
+ complete,
+ confirmed,
+ // ownIndex is part of the working set the coordinator needs; surface it on the
+ // view object (DkgSession allows it structurally via the indexer below).
+ ...(ownIndex !== undefined ? { ownIndex } : {}),
+ } as DkgSession & { ownIndex?: number })
+ }
+
+ // Newest invite first, matching displayedQuora's ordering.
+ return sessions.sort((a, b) => {
+ const ca = invites.find(e => e.id === a.inviteId)?.created_at ?? 0
+ const cb = invites.find(e => e.id === b.inviteId)?.created_at ?? 0
+ return cb - ca
+ })
+}
+
+/** Reactive DKG session views. Tracks repository events + progress when read in an effect/memo. */
+export function dkgSessions(): DkgSession[] {
+ progressTick() // create a dependency so flag changes re-derive
+ const mine = me()
+ if (!mine) { return [] }
+ return deriveDkgSessions(mine)
+}
+
+// ── Equivocation detection ─────────────────────────────────────────────────────
+// PROTOCOL.md: if any two confirmations for one invite carry a different transcript
+// or quorum, all participants must abort. Our own 7053 is stored locally, so its
+// values are naturally in this set — covering local-vs-peer divergence too.
+// Only confirmations from actual members count: a relay/attacker can deliver a spoofed
+// 7053 from a non-member referencing our inviteId; counting it would let an outsider
+// force a false abort (DoS). Cryptographic equivocation is strictly among participants.
+function isEquivocated(inviteId: string, members: string[]): boolean {
+ const confirms = eventsForInvite(dkgConfirmEvents(), inviteId)
+ .filter(e => members.includes(e.pubkey))
+ if (confirms.length < 2) { return false }
+ const transcripts = new Set(confirms.map(e => getTagValue("transcript", e.tags)))
+ const quora = new Set(confirms.map(e => quorumOf(e)))
+ return transcripts.size > 1 || quora.size > 1
+}
+
+// ── Coordinator ────────────────────────────────────────────────────────────────
+// One createEffect that re-runs on every repository / progress change and advances
+// each session to its next reachable step. Every send is guarded by a persisted
+// progress flag (the cross-tick / cross-reload guard) PLUS a synchronous in-flight
+// guard (below). This is a fixpoint loop: flag flips retrigger derivation until no
+// further action is possible.
+//
+// Why the in-flight set is required: a step's progress flag can only be set AFTER
+// the async work that produces its payload (encryptPoly / decryptPoly / saveQuorum).
+// Between the first `await` and `patchProgress`, the effect can re-run (another
+// repository change) and re-enter the same step with the flag still unset — running
+// it twice concurrently. For Round 1 that means sampling TWO different polynomials and
+// broadcasting conflicting commitments (self-equivocation → everyone aborts); for
+// Round 2 a double per-recipient send; for finalize a double saveQuorum/confirm. The
+// in-flight set is checked-and-added SYNCHRONOUSLY at the top of each async step
+// (before any await) and removed in finally, so only one pass per (session, step) runs
+// at a time. The persisted flag still prevents re-runs across ticks and reloads.
+const inFlight = new Set()
+
+export function startDkg(): void {
+ createEffect(() => {
+ for (const s of dkgSessions()) {
+ void advanceDkg(s) // fire-and-forget; each step is guarded
+ }
+ })
+}
+
+function ownIndexOf(s: DkgSession): number | undefined {
+ return (s as DkgSession & { ownIndex?: number }).ownIndex
+}
+
+async function advanceDkg(s: DkgSession): Promise {
+ const id = s.inviteId
+ const p = getProgress(id)
+ if (p.aborted || p.declined) { return }
+
+ const ownIndex = ownIndexOf(s)
+ if (ownIndex === undefined) { return }
+
+ const mine = me()
+ const indexed = assignIndices(s.members)
+ const others = indexed.filter(m => m.pubkey !== mine)
+ const t = s.threshold
+
+ // Abort short-circuit: conflicting confirmations → mark aborted, do nothing more.
+ if (isEquivocated(id, s.members)) {
+ if (!p.aborted) {
+ patchProgress(id, { aborted: true })
+ toast.error("DKG aborted: conflicting confirmations from a participant")
+ }
+ return
+ }
+
+ try {
+ // ── STEP A — creator auto-joins (runs Round 1). ──
+ // Non-creators run Round 1 only via the explicit acceptDkg user action, because
+ // participation in Round 1 signals acceptance (PROTOCOL.md). The creator already
+ // consented by creating the invite, so the coordinator joins them automatically.
+ const inv = inviteEvents().find(e => e.id === id)
+ if (inv?.pubkey === mine && !p.sentRound1) {
+ await runRound1(s, ownIndex, others)
+ return
+ }
+ if (!p.sentRound1) { return } // a non-creator member waiting for acceptDkg
+
+ // ── STEP C — send Round 2 once ALL Round 1 received and verified. ──
+ if (!p.sentRound2) {
+ if (!round1Complete(s, mine, others)) { return }
+ for (const m of others) {
+ const r1 = s.round1[m.pubkey]
+ // Wrong polynomial degree → their commitments can't finalize → abort.
+ if (r1.commitments.length !== t) {
+ patchProgress(id, { aborted: true })
+ toast.error(`Invalid round-1 (wrong threshold) from ${m.pubkey.slice(0, 8)}`)
+ return
+ }
+ if (!verifyRound1(id, m.pubkey, r1.commitments, r1.proof)) {
+ patchProgress(id, { aborted: true })
+ toast.error(`Invalid round-1 proof from ${m.pubkey.slice(0, 8)}`)
+ return
+ }
+ }
+ // Claim the step synchronously (before any await) so a re-entrant pass that still
+ // sees sentRound2 === false cannot also send Round 2.
+ const key = `${id}:round2`
+ if (inFlight.has(key)) { return }
+ inFlight.add(key)
+ try {
+ const poly = await decryptPoly(p.encryptedPoly!)
+ // Persist the guard BEFORE the sends so the next tick (and a reload) skips this step.
+ patchProgress(id, { sentRound2: true })
+ await Promise.all(others.map(async m => {
+ // Per-recipient: each member gets a DIFFERENT share fᵢ(m.index). We use
+ // deliverTo directly (NOT sendProtocolEvent) so we do NOT store our outgoing
+ // 7052 locally — that would pollute round2, which must hold only peers' shares.
+ const rumor = dkgRound2(mine, id, poly, m.index)
+ await loadMessagingRelayList(m.pubkey).catch(() => {})
+ await deliverTo(m.pubkey, rumor).catch(e => console.error("dkg round-2 deliver failed", m.pubkey, e))
+ }))
+ } finally {
+ inFlight.delete(key)
+ }
+ return
+ }
+
+ // ── STEP D — finalize + confirm once ALL Round-2 shares received. ──
+ if (!p.sentConfirm) {
+ if (!others.every(m => s.round2[m.index] !== undefined)) { return }
+
+ // allCommitments: participant index → that member's verified Feldman commitments.
+ const allCommitments: Record = {}
+ for (const m of indexed) {
+ const c = s.round1[m.pubkey]?.commitments
+ if (!c) { return } // race: a Round-1 went missing; wait for it
+ allCommitments[m.index] = c
+ }
+
+ // Claim the step synchronously so a re-entrant pass (flag still unset across the
+ // decrypt/save awaits) cannot double-finalize and double-confirm. A throw from
+ // dkgFinalize still propagates to the outer catch (sets aborted); finally only
+ // releases the in-flight latch.
+ const key = `${id}:finalize`
+ if (inFlight.has(key)) { return }
+ inFlight.add(key)
+ try {
+ const poly = await decryptPoly(p.encryptedPoly!)
+ // Own self-share fᵢ(ownIndex): the protocol sums sᵢⱼ over ALL i including i=j,
+ // and we never send ourselves a 7052. Reuse dkgRound2 at our own index so the
+ // self-share is computed identically to peers' (no separate evalPoly export).
+ const ownShare = shareOf(dkgRound2(mine, id, poly, ownIndex) as unknown as TrustedEvent)!
+ const shares: Record = { ...s.round2, [ownIndex]: ownShare }
+
+ // dkgFinalize verifies every share against the commitments and may throw → abort.
+ const { state, transcript } = dkgFinalize(id, ownIndex, indexed, allCommitments, shares)
+
+ // Persist the shard BEFORE confirming: a crash between confirm and save must not
+ // leave peers thinking we're done while we've lost our shard.
+ await saveQuorum(state)
+ patchProgress(id, {
+ finalized: true,
+ sentConfirm: true,
+ myTranscript: transcript,
+ myQuorumPubkey: state.quorumPubkey,
+ })
+ const confirm = dkgConfirm(mine, id, state.quorumPubkey, transcript)
+ await sendProtocolEvent(confirm, others.map(m => m.pubkey))
+ } finally {
+ inFlight.delete(key)
+ }
+ return
+ }
+
+ // ── STEP E — completion is a view-only transition; nothing to send. ──
+ } catch (err) {
+ // No signer (logged out mid-session) is transient: encrypt/decrypt and delivery
+ // throw, but the state is fine — retry when the signer returns; do NOT abort.
+ if (!signer.get()) { return }
+ console.error("dkg advance failed", id, err)
+ toast.error(err instanceof Error ? err.message : "DKG step failed")
+ patchProgress(id, { aborted: true })
+ }
+}
+
+// Round 1 is complete once every member (peers + me) has a verified-or-pending entry.
+function round1Complete(s: DkgSession, mine: string, others: QuorumMember[]): boolean {
+ return Boolean(s.round1[mine]) && others.every(m => Boolean(s.round1[m.pubkey]))
+}
+
+// Shared by acceptDkg (non-creators) and the coordinator's creator auto-join (Step A).
+// CRITICAL idempotency: Round 1 samples a fresh polynomial and Feldman commitments. If
+// this ran twice concurrently (e.g. a coordinator re-entry during the encryptPoly await,
+// or a double-clicked acceptDkg) we would broadcast TWO different commitment sets and
+// persist a polynomial matching only one of them — self-equivocation that aborts the DKG
+// for every member. The synchronous in-flight latch below admits exactly one run per
+// session; the persisted sentRound1 flag blocks subsequent ticks and reloads. A caller
+// that loses the latch race must NOT proceed, so we surface that via the return value.
+async function runRound1(s: DkgSession, ownIndex: number, others: QuorumMember[]): Promise {
+ const id = s.inviteId
+ // Re-check the persisted flag synchronously (acceptDkg checked it earlier, but an await
+ // may have elapsed since) and claim the latch before sampling anything.
+ if (getProgress(id).sentRound1) { return }
+ const key = `${id}:round1`
+ if (inFlight.has(key)) { return }
+ inFlight.add(key)
+ try {
+ const mine = me()
+ const { rumor, state } = dkgRound1(mine, id, s.threshold)
+ // Persist the polynomial NIP-44-self-encrypted; NEVER plaintext. It must survive
+ // reload so Round 2 / finalize can decrypt it after a refresh.
+ const encryptedPoly = await encryptPoly(state.polynomial)
+ // Persist the guard BEFORE broadcasting so the next tick / a reload skips this step.
+ patchProgress(id, { ownIndex, encryptedPoly, sentRound1: true })
+ await sendProtocolEvent(rumor, others.map(m => m.pubkey)) // broadcast 7051, stores local copy
+ } finally {
+ inFlight.delete(key)
+ }
+}
+
+// ── User actions ───────────────────────────────────────────────────────────────
+
+/**
+ * Create a quorum: build and deliver the kind 7050 invite. (Moved here from
+ * src/quorum.ts.) Optimistic — the invite is stored locally so the quorum appears
+ * immediately; delivery success/failure is surfaced via setDelivery and never blocks
+ * creation. The creator's Round 1 is driven by the coordinator (Step A) afterward.
+ *
+ * All protocol events are gift-wrapped to recipients, so the invite is delivered via
+ * sendProtocolEvent (which stores a local copy AND wraps to each member's inbox) —
+ * unlike the old plain publish, which never reached members.
+ *
+ * Returns the invite id (the quorum's identifier until DKG completes).
+ */
+export async function createQuorum(opts: {
+ members: string[]
+ threshold: number
+ message?: string
+}): Promise {
+ const pk = pubkey.get()
+ if (!pk) { throw new Error("You must be logged in to create a quorum") }
+
+ // The creator is always a member of their own quorum.
+ const members = Array.from(new Set([pk, ...opts.members]))
+ const invite = createInvite(pk, members, opts.threshold, opts.message ?? "")
+ const inviteId = invite.id
+
+ // Show it (and select it) right away, before the network round-trip.
+ setDelivery(inviteId, "sending")
+ openQuorum(inviteId)
+
+ try {
+ await sendProtocolEvent(invite, members.filter(m => m !== pk))
+ setDelivery(inviteId, "saved")
+ } catch (err) {
+ console.error("createQuorum delivery failed", inviteId, err)
+ setDelivery(inviteId, "failed")
+ }
+
+ return inviteId
+}
+
+/**
+ * Accept a DKG invite: run Round 1 (sampling the polynomial, persisting it encrypted,
+ * broadcasting commitments). Participation in Round 1 IS the acceptance signal. The
+ * coordinator drives Round 2 / finalize / confirm from here.
+ *
+ * An optional message is held in progress and posted to the quorum chat after
+ * finalization (the quorum pubkey does not exist yet during DKG).
+ */
+export async function acceptDkg(inviteId: string, message?: string): Promise {
+ const mine = me()
+ if (!mine) {
+ toast.error("You must be logged in to accept")
+ return
+ }
+
+ const session = dkgSessions().find(s => s.inviteId === inviteId)
+ if (!session) {
+ toast.error("Invite not found")
+ return
+ }
+ const ownIndex = ownIndexOf(session)
+ if (ownIndex === undefined) {
+ toast.error("You are not a member of this quorum")
+ return
+ }
+
+ const p = getProgress(inviteId)
+
+ // Post the optional accept message to the quorum chat immediately. Chat is plain NIP-17
+ // and works before the quorum's key exists — the member set is known from the invite —
+ // so there is no need to hold the message until finalization.
+ if (message) {
+ void sendChatMessage(inviteId, session.members, message).catch(e =>
+ console.error("post accept message failed", inviteId, e))
+ }
+
+ if (p.sentRound1) { return } // already accepted
+
+ // Rejoining after a local decline is allowed before others act on it.
+ if (p.declined) { patchProgress(inviteId, { declined: false }) }
+
+ const indexed = assignIndices(session.members)
+ const others = indexed.filter(m => m.pubkey !== mine)
+
+ try {
+ await runRound1(session, ownIndex, others)
+ } catch (err) {
+ if (!signer.get()) {
+ toast.error("Cannot accept while logged out")
+ return
+ }
+ console.error("acceptDkg failed", inviteId, err)
+ toast.error(err instanceof Error ? err.message : "Failed to accept invite")
+ }
+}
+
+/**
+ * Decline a DKG invite: send a kind 7061 to the initiator only (the quorum pubkey
+ * does not exist for a creation invite, so no quorum tag). A decline is informational
+ * — it stops US from participating and signals the initiator to restart; it does not
+ * abort the session for other members.
+ */
+export async function declineDkg(inviteId: string, reason?: string): Promise {
+ const mine = me()
+ if (!mine) {
+ toast.error("You must be logged in to decline")
+ return
+ }
+
+ const p = getProgress(inviteId)
+ if (p.declined) { return }
+
+ patchProgress(inviteId, { declined: true })
+
+ // No quorumPubkey arg — the key does not exist for a creation invite.
+ const decline = createDecline(mine, inviteId, undefined, reason ?? "")
+ const inv = inviteEvents().find(e => e.id === inviteId)
+ const initiator = inv?.pubkey
+
+ try {
+ // Stores a local copy (so declines[me] derives) and delivers to the initiator.
+ await sendProtocolEvent(decline, initiator && initiator !== mine ? [initiator] : [])
+ } catch (err) {
+ if (!signer.get()) { return }
+ console.error("declineDkg delivery failed", inviteId, err)
+ }
+}
diff --git a/src/engine/events.ts b/src/engine/events.ts
new file mode 100644
index 0000000..46f2ce8
--- /dev/null
+++ b/src/engine/events.ts
@@ -0,0 +1,102 @@
+import { createSignal } from "solid-js"
+import { deriveEvents } from "@welshman/store"
+import { repository } from "@welshman/app"
+import { getTagValue, getTagValues } from "@welshman/util"
+import type { TrustedEvent } from "@welshman/util"
+import type { Hex } from "../protocol"
+
+// ── Per-kind repository-derived signals ───────────────────────────────────────
+// One subscription per protocol kind, alive for the app lifetime — the same
+// pattern src/store.ts uses. Each returns a Solid accessor of the current set of
+// events of that kind in the repository (inbound rumors + our own local copies).
+
+function kindSignal(kind: number): () => TrustedEvent[] {
+ const [get, set] = createSignal([])
+ deriveEvents({ repository, filters: [{ kinds: [kind] }] }).subscribe(set)
+ return get
+}
+
+export const inviteEvents = kindSignal(7050)
+export const dkgRound1Events = kindSignal(7051)
+export const dkgRound2Events = kindSignal(7052)
+export const dkgConfirmEvents = kindSignal(7053)
+export const reshareProposalEvents = kindSignal(7054)
+export const reshareRound1Events = kindSignal(7055)
+export const reshareRound2Events = kindSignal(7056)
+export const reshareConfirmEvents = kindSignal(7057)
+export const signRequestEvents = kindSignal(7058)
+export const signNonceEvents = kindSignal(7059)
+export const signShareEvents = kindSignal(7060)
+export const declineEvents = kindSignal(7061)
+
+// ── Tag parsers ───────────────────────────────────────────────────────────────
+// The session id is the initiating event's inner id, referenced via ["e", id].
+
+export const sessionId = (e: TrustedEvent): string | undefined => getTagValue("e", e.tags)
+
+export const quorumOf = (e: TrustedEvent): Hex | undefined => getTagValue("quorum", e.tags)
+
+export const commitsOf = (e: TrustedEvent): Hex[] => getTagValues("commit", e.tags)
+
+export function proofOf(e: TrustedEvent): { R: Hex; s: Hex } | undefined {
+ const tag = e.tags.find(t => t[0] === "proof")
+ if (!tag || tag[1] === undefined || tag[2] === undefined) { return undefined }
+ return { R: tag[1], s: tag[2] }
+}
+
+export const shareOf = (e: TrustedEvent): Hex | undefined => getTagValue("share", e.tags)
+
+export const membersOf = (e: TrustedEvent): Hex[] => getTagValues("member", e.tags)
+
+export const thresholdOf = (e: TrustedEvent): number => Number(getTagValue("threshold", e.tags) ?? "1")
+
+export const contributorsOf = (e: TrustedEvent): Hex[] => getTagValues("contributor", e.tags)
+
+/** Parse ["dkg_commit", pk, ...commits] tags into pubkey → ordered commitments. */
+export function dkgCommitsOf(e: TrustedEvent): Record {
+ const out: Record = {}
+ for (const tag of e.tags) {
+ if (tag[0] !== "dkg_commit" || tag[1] === undefined) { continue }
+ out[tag[1]] = tag.slice(2)
+ }
+ return out
+}
+
+export function noncesOf(e: TrustedEvent): { D: Hex; E: Hex } | undefined {
+ const D = getTagValue("D", e.tags)
+ const E = getTagValue("E", e.tags)
+ if (D === undefined || E === undefined) { return undefined }
+ return { D, E }
+}
+
+export const zOf = (e: TrustedEvent): Hex | undefined => getTagValue("z", e.tags)
+
+export const signerIndicesOf = (e: TrustedEvent): number[] => getTagValues("signer", e.tags).map(Number)
+
+export const reasonOf = (e: TrustedEvent): string => e.content
+
+// ── Grouping helpers ──────────────────────────────────────────────────────────
+
+/** Group events by their session id (the ["e"] value); events without one are dropped. */
+export function groupBySession(events: TrustedEvent[]): Map {
+ const out = new Map()
+ for (const e of events) {
+ const id = sessionId(e)
+ if (!id) { continue }
+ const list = out.get(id)
+ if (list) { list.push(e) } else { out.set(id, [e]) }
+ }
+ return out
+}
+
+/** Dedupe events to the newest per author (ties broken by id for determinism). */
+export function latestByAuthor(events: TrustedEvent[]): Map {
+ const out = new Map()
+ for (const e of events) {
+ const prev = out.get(e.pubkey)
+ if (!prev || e.created_at > prev.created_at || (e.created_at === prev.created_at && e.id > prev.id)) {
+ out.set(e.pubkey, e)
+ }
+ }
+ return out
+}
diff --git a/src/engine/index.ts b/src/engine/index.ts
new file mode 100644
index 0000000..0ed8f88
--- /dev/null
+++ b/src/engine/index.ts
@@ -0,0 +1,19 @@
+import { startDkg } from "./dkg"
+import { startResharing } from "./resharing"
+import { startSigning } from "./signing"
+
+// ── Engine entry point ─────────────────────────────────────────────────────────
+// Starts all three reactive ceremony coordinators. Called once from boot.ts after
+// the repository is hydrating and gift-wrap unwrapping is enabled. Each coordinator
+// is idempotent and processes already-hydrated events on its first run, so ordering
+// relative to hydration does not matter.
+
+let started = false
+
+export function startEngine(): void {
+ if (started) { return }
+ started = true
+ startDkg()
+ startResharing()
+ startSigning()
+}
diff --git a/src/engine/notifications.ts b/src/engine/notifications.ts
new file mode 100644
index 0000000..8896925
--- /dev/null
+++ b/src/engine/notifications.ts
@@ -0,0 +1,116 @@
+import { createSignal } from "solid-js"
+import { synced, localStorageProvider, deriveEvents } from "@welshman/store"
+import { now } from "@welshman/lib"
+import { pubkey, repository } from "@welshman/app"
+import { getTagValue } from "@welshman/util"
+import type { TrustedEvent } from "@welshman/util"
+import { PROTOCOL_KINDS } from "../protocol"
+import type { DisplayedQuorum } from "../models"
+
+// ── Notification checkpoints ────────────────────────────────────────────────────
+// A "checkpoint" is the timestamp up to which a quorum's tab has been read. A tab has
+// new activity if it holds any event (from someone other than us) newer than its
+// checkpoint. Checkpoints fall back to a per-session `baseline`, which login sets to
+// 7 days ago so older history isn't flagged as new.
+
+export type QuorumTab = "log" | "members" | "chat"
+
+const WEEK = 7 * 24 * 60 * 60
+const CHAT_KIND = 14
+// Kinds that establish or change a quorum's member set (DKG complete, rotation complete).
+const MEMBERSHIP_KINDS = [7053, 7057]
+
+type NotificationState = { baseline: number; lastChecked: Record }
+
+const notifications = synced({
+ key: "nq:notifications",
+ storage: localStorageProvider,
+ defaultValue: { baseline: 0, lastChecked: {} },
+})
+
+// `synced` is a plain svelte writable (no `.get()`); read the latest synchronously for
+// writes, and mirror it into a Solid signal so badge derivations recompute reactively.
+function read(): NotificationState {
+ let value!: NotificationState
+ notifications.subscribe(v => { value = v })()
+ return value
+}
+
+const [state, setState] = createSignal(read())
+notifications.subscribe(setState)
+
+const [me, setMe] = createSignal("")
+pubkey.subscribe(pk => {
+ setMe(pk ?? "")
+ // First session ever (e.g. a restored login predating this feature): seed the baseline
+ // so existing history isn't all flagged as new. Explicit logins reset it via onLogin().
+ if (pk && read().baseline === 0) {
+ notifications.set({ ...read(), baseline: now() - WEEK })
+ }
+})
+
+// Account-scoped so two local accounts in the same quorum don't share read state.
+const checkpointKey = (pk: string, quorumId: string, tab: QuorumTab) => `${pk}:${quorumId}:${tab}`
+
+/** Reset every checkpoint to 7 days ago. Call on login. */
+export function onLogin(): void {
+ notifications.set({ baseline: now() - WEEK, lastChecked: {} })
+}
+
+/** Mark a quorum's tab read up to now. Called continuously while the tab is on screen. */
+export function markTabViewed(quorumId: string, tab: QuorumTab): void {
+ const pk = me()
+ if (!pk) { return }
+ const s = read()
+ notifications.set({ ...s, lastChecked: { ...s.lastChecked, [checkpointKey(pk, quorumId, tab)]: now() } })
+}
+
+function checkpoint(quorumId: string, tab: QuorumTab): number {
+ const s = state()
+ return s.lastChecked[checkpointKey(me(), quorumId, tab)] ?? s.baseline
+}
+
+// ── Reactive event streams ──────────────────────────────────────────────────────
+const [allProtocol, setAllProtocol] = createSignal([])
+deriveEvents({ repository, filters: [{ kinds: PROTOCOL_KINDS }] }).subscribe(setAllProtocol)
+
+const [allChat, setAllChat] = createSignal([])
+deriveEvents({ repository, filters: [{ kinds: [CHAT_KIND] }] }).subscribe(setAllChat)
+
+// A quorum's protocol events: the invite, anything that "e"-tags it (DKG rounds/declines),
+// and once established anything carrying its quorum tag (confirmations, resharing, signing).
+function protocolEventsFor(q: DisplayedQuorum): TrustedEvent[] {
+ const inviteId = q.inviteId
+ const quorumPubkey = q.quorumPubkey
+ return allProtocol().filter(e =>
+ e.id === inviteId ||
+ getTagValue("e", e.tags) === inviteId ||
+ (quorumPubkey !== undefined && getTagValue("quorum", e.tags) === quorumPubkey))
+}
+
+function chatEventsFor(q: DisplayedQuorum): TrustedEvent[] {
+ const ids = new Set([q.inviteId, q.quorumPubkey].filter((x): x is string => Boolean(x)))
+ return allChat().filter(e => {
+ const t = getTagValue("quorum", e.tags)
+ return t !== undefined && ids.has(t)
+ })
+}
+
+function tabEvents(q: DisplayedQuorum, tab: QuorumTab): TrustedEvent[] {
+ if (tab === "chat") { return chatEventsFor(q) }
+ const proto = protocolEventsFor(q)
+ if (tab === "members") { return proto.filter(e => MEMBERSHIP_KINDS.includes(e.kind)) }
+ return proto // log: full protocol history
+}
+
+/** Whether a quorum's tab has activity (from others) newer than its read checkpoint. */
+export function tabHasActivity(q: DisplayedQuorum, tab: QuorumTab): boolean {
+ const cp = checkpoint(q.inviteId, tab)
+ const mine = me()
+ return tabEvents(q, tab).some(e => e.created_at > cp && e.pubkey !== mine)
+}
+
+/** Whether a quorum has any new activity across its tabs (drives the nav badge). */
+export function quorumHasActivity(q: DisplayedQuorum): boolean {
+ return tabHasActivity(q, "log") || tabHasActivity(q, "members") || tabHasActivity(q, "chat")
+}
diff --git a/src/engine/resharing.ts b/src/engine/resharing.ts
new file mode 100644
index 0000000..d83dd72
--- /dev/null
+++ b/src/engine/resharing.ts
@@ -0,0 +1,642 @@
+import { createSignal, createEffect } from "solid-js"
+import { toast } from "solid-toast"
+import { pubkey, signer, repository, loadMessagingRelayList } from "@welshman/app"
+import { getTagValue } from "@welshman/util"
+import type { TrustedEvent } from "@welshman/util"
+import {
+ createResharingProposal,
+ resharingRound1,
+ verifyResharingRound1,
+ resharingRound2,
+ resharingFinalize,
+ createDecline,
+ assignIndices,
+} from "../protocol"
+import type { Hex } from "../protocol"
+import type { ResharingSession, Round1Data, ResharingPhase, QuorumRecord } from "../models"
+import { encryptPoly, decryptPoly } from "../models"
+import {
+ reshareProposalEvents,
+ reshareRound1Events,
+ reshareRound2Events,
+ reshareConfirmEvents,
+ declineEvents,
+ sessionId,
+ quorumOf,
+ commitsOf,
+ proofOf,
+ shareOf,
+ membersOf,
+ thresholdOf,
+ contributorsOf,
+ dkgCommitsOf,
+ reasonOf,
+ groupBySession,
+ latestByAuthor,
+} from "./events"
+import {
+ getProgress,
+ patchProgress,
+ getQuorumRecord,
+ loadShard,
+ saveQuorum,
+ sessionProgress,
+ quora,
+ shardRecords,
+} from "./secrets"
+import { sendProtocolEvent, deliverTo } from "./delivery"
+import { sendChatMessage } from "./chat"
+
+// ── Local helpers ─────────────────────────────────────────────────────────────
+
+const transcriptOf = (e: TrustedEvent): string | undefined => getTagValue("transcript", e.tags)
+
+const unique = (xs: T[]): T[] => Array.from(new Set(xs))
+
+/**
+ * A resharing session as derived from the repository, extended with runtime flags
+ * the coordinator and UI need. It is a structural superset of `ResharingSession`, so
+ * existing UI typings (ResharingInviteResponse etc.) keep compiling.
+ */
+export type ResharingSessionView = ResharingSession & {
+ /** The initiator (kind 7054 author) */
+ initiator: Hex
+ iAmContributor: boolean
+ iAmNewMember: boolean
+ iAmInitiator: boolean
+ aborted: boolean
+ declined: boolean
+ /** pubkey → ordered Feldman commitments for every CURRENT member (from the proposal) */
+ dkgCommitsByPk: Record
+ /** All kind 7057 confirmations (not deduped) for equivocation detection */
+ confirms: { pubkey: Hex; quorum?: Hex; transcript?: string }[]
+ round1Count: number
+ confirmCount: number
+ expectedConfirms: number
+ declinedCount: number
+}
+
+// ── Reactive bridges from the persisted (svelte) stores ────────────────────────
+// The synced stores are not Solid signals; bridge each via a module-scope signal fed
+// by `.subscribe`, the same way src/store.ts bridges welshman stores. Reading these in
+// the derivation effect makes it recompute when persisted progress / quora / shards change.
+
+const [progressMirror, setProgressMirror] = createSignal>>({})
+const [quoraMirror, setQuoraMirror] = createSignal(undefined)
+const [shardsMirror, setShardsMirror] = createSignal(undefined)
+
+sessionProgress.subscribe(v => setProgressMirror(v))
+quora.subscribe(v => setQuoraMirror(v))
+shardRecords.subscribe(v => setShardsMirror(v))
+
+// ── Derived session list ───────────────────────────────────────────────────────
+
+const [sessions, setSessions] = createSignal([])
+
+createEffect(() => {
+ const me = pubkey.get()
+ // Touch the store mirrors so this recomputes on persisted-state changes.
+ progressMirror()
+ quoraMirror()
+ shardsMirror()
+
+ if (!me) {
+ setSessions([])
+ return
+ }
+
+ const proposals = reshareProposalEvents()
+ const round1BySession = groupBySession(reshareRound1Events())
+ const round2BySession = groupBySession(reshareRound2Events())
+ const confirmBySession = groupBySession(reshareConfirmEvents())
+ const declineBySession = groupBySession(declineEvents())
+
+ const views: ResharingSessionView[] = []
+
+ for (const p of proposals) {
+ const proposalId = p.id
+ const quorumPubkey = quorumOf(p) ?? ""
+ const contributors = contributorsOf(p)
+ const newMembers = membersOf(p)
+ const newThreshold = thresholdOf(p)
+ const dkgCommitsByPk = dkgCommitsOf(p)
+ const initiator = p.pubkey
+
+ const iAmContributor = contributors.includes(me)
+ const iAmNewMember = newMembers.includes(me)
+ const iAmInitiator = initiator === me
+
+ // Only surface sessions the user participates in.
+ if (!iAmContributor && !iAmNewMember && !iAmInitiator) { continue }
+
+ // Round 1 (7055): newest per author, keyed by contributor pubkey. Ignore non-contributors.
+ const round1: Record = {}
+ for (const [author, e] of latestByAuthor(round1BySession.get(proposalId) ?? [])) {
+ if (!contributors.includes(author)) { continue }
+ const proof = proofOf(e)
+ if (!proof) { continue }
+ round1[author] = { commitments: commitsOf(e), proof }
+ }
+
+ // Round 2 (7056): per-recipient shares addressed to us; newest per sender. Re-key by the
+ // sender's ORIGINAL contributor index (derived from the proposal's current member set).
+ const currentMembers = assignIndices(Object.keys(dkgCommitsByPk))
+ const indexByPubkey = new Map(currentMembers.map(m => [m.pubkey, m.index]))
+ const round2: Record = {}
+ for (const [author, e] of latestByAuthor(round2BySession.get(proposalId) ?? [])) {
+ if (!contributors.includes(author)) { continue }
+ const idx = indexByPubkey.get(author)
+ const share = shareOf(e)
+ if (idx === undefined || share === undefined) { continue }
+ round2[idx] = share
+ }
+
+ // Confirmations (7057): only new members legitimately finalize and confirm, so ignore
+ // any 7057 from a non-new-member — otherwise an outsider could inject a divergent
+ // transcript/quorum and force a false abort, or inflate the confirmation count. Keep ALL
+ // (deduped only on the count) confirmations from new members for equivocation detection.
+ const newMemberSet = new Set(newMembers)
+ const confirmEvents = (confirmBySession.get(proposalId) ?? []).filter(e => newMemberSet.has(e.pubkey))
+ const confirms = confirmEvents.map(e => ({
+ pubkey: e.pubkey,
+ quorum: quorumOf(e),
+ transcript: transcriptOf(e),
+ }))
+ const confirmCount = latestByAuthor(confirmEvents).size
+
+ // Declines (7061): newest per author, pubkey → reason. Only an actual participant
+ // (contributor or prospective new member) can meaningfully decline; ignore outsiders so
+ // the declined count is not inflated by unrelated pubkeys.
+ const declines: Record = {}
+ for (const [author, e] of latestByAuthor(declineBySession.get(proposalId) ?? [])) {
+ if (!contributors.includes(author) && !newMembers.includes(author)) { continue }
+ declines[author] = reasonOf(e)
+ }
+
+ const progress = getProgress(proposalId)
+ const aborted = progress.aborted === true
+ const declined = progress.declined === true
+
+ const phase = computePhase(proposalId, quorumPubkey, progress, contributors, round1, confirmCount)
+
+ views.push({
+ proposalId,
+ quorumPubkey,
+ contributors,
+ newMembers,
+ newThreshold,
+ phase,
+ myCommitments: undefined,
+ myEncryptedPoly: progress.encryptedPoly,
+ round1,
+ round2,
+ declines,
+ initiator,
+ iAmContributor,
+ iAmNewMember,
+ iAmInitiator,
+ aborted,
+ declined,
+ dkgCommitsByPk,
+ confirms,
+ round1Count: Object.keys(round1).length,
+ confirmCount,
+ expectedConfirms: newMembers.length,
+ declinedCount: Object.keys(declines).length,
+ })
+ }
+
+ setSessions(views)
+})
+
+function computePhase(
+ proposalId: string,
+ quorumPubkey: string,
+ progress: ReturnType,
+ contributors: Hex[],
+ round1: Record,
+ confirmCount: number,
+): ResharingPhase {
+ const rec = getQuorumRecord(quorumPubkey)
+ if ((rec && rec.rotationRecords.includes(proposalId)) || progress.finalized) {
+ return "complete"
+ }
+ if (progress.sentRound2 || confirmCount >= 1) {
+ return "confirming"
+ }
+ const allRound1 = contributors.length > 0 && contributors.every(c => round1[c])
+ if (progress.sentRound1 || allRound1) {
+ return "round2"
+ }
+ return "round1"
+}
+
+/** Reactive accessor for the resharing session list (UI + coordinator). */
+export function resharingSessions(): ResharingSessionView[] {
+ return sessions()
+}
+
+function findSession(proposalId: string): ResharingSessionView | undefined {
+ return sessions().find(s => s.proposalId === proposalId)
+}
+
+// ── Initiate (kind 7054) ───────────────────────────────────────────────────────
+
+export async function proposeResharing(opts: {
+ quorumPubkey: string
+ newMembers: string[]
+ newThreshold: number
+ contributors: string[]
+ message?: string
+}): Promise {
+ const me = pubkey.get()
+ if (!me) { throw new Error("You must be logged in to propose a rotation") }
+
+ const rec = getQuorumRecord(opts.quorumPubkey)
+ if (!rec) { throw new Error("Unknown quorum") }
+
+ const currentPubkeys = rec.members.map(m => m.pubkey)
+ if (!opts.contributors.every(c => currentPubkeys.includes(c))) {
+ throw new Error("All contributors must be current members")
+ }
+ if (opts.contributors.length < rec.threshold) {
+ throw new Error(`At least ${rec.threshold} contributors are required`)
+ }
+ if (opts.newMembers.length === 0) {
+ throw new Error("Select at least one new member")
+ }
+ if (opts.newThreshold < 1 || opts.newThreshold > opts.newMembers.length) {
+ throw new Error("Threshold must be between 1 and the number of new members")
+ }
+
+ // members = the CURRENT members (with original indices); commitments keyed by original index.
+ // createResharingProposal serialises one ["dkg_commit", pk, ...commitments[index]] per member.
+ const rumor = createResharingProposal(
+ me,
+ opts.quorumPubkey,
+ rec.members,
+ opts.contributors,
+ opts.newMembers,
+ opts.newThreshold,
+ rec.commitments,
+ opts.message ?? "",
+ )
+ const proposalId = rumor.id
+
+ // Recipients = (current ∪ new) \ {me}. Contributors run round 1; new members verify+finalize.
+ const recipients = unique([...currentPubkeys, ...opts.newMembers]).filter(pk => pk !== me)
+
+ // Best-effort: warm messaging relay lists so delivery can resolve inbox relays.
+ await Promise.all(recipients.map(pk => loadMessagingRelayList(pk).catch(() => {})))
+
+ // Optimistic local store + best-effort gift-wrapped delivery. Creation must not fail
+ // just because delivery did — the proposal's presence in the repository is its own guard.
+ try {
+ await sendProtocolEvent(rumor, recipients)
+ } catch (e) {
+ console.error("propose resharing delivery failed", e)
+ toast.error("Rotation proposed, but delivery to some members failed")
+ }
+
+ return proposalId
+}
+
+// ── Accept / decline ────────────────────────────────────────────────────────────
+
+export async function acceptResharing(proposalId: string, message?: string): Promise {
+ const s = findSession(proposalId)
+ if (!s) { throw new Error("Unknown resharing proposal") }
+
+ const me = pubkey.get()
+ if (!me) { throw new Error("Not logged in") }
+
+ if (getProgress(proposalId).declined) { throw new Error("Already declined") }
+
+ // Mark intent; the coordinator (createEffect) observes this consent and advances the
+ // session idempotently. We never run crypto here, so accept can't race the coordinator.
+ acceptedSessions.add(proposalId)
+
+ if (message && s.quorumPubkey) {
+ // Reach everyone involved in the rotation (old contributors + new members).
+ const members = Array.from(new Set([...s.contributors, ...s.newMembers]))
+ await sendChatMessage(s.quorumPubkey, members, message).catch(e => console.error("chat failed", e))
+ }
+}
+
+export async function declineResharing(proposalId: string, reason?: string): Promise {
+ const s = findSession(proposalId)
+ if (!s) { throw new Error("Unknown resharing proposal") }
+
+ const me = pubkey.get()
+ if (!me) { throw new Error("Not logged in") }
+
+ if (getProgress(proposalId).declined) { return }
+ patchProgress(proposalId, { declined: true })
+
+ const proposal = reshareProposalEvents().find(e => e.id === proposalId)
+ const initiator = proposal?.pubkey
+
+ // For resharing the quorum key is already known, so it is included in the decline.
+ const rumor = createDecline(me, proposalId, s.quorumPubkey || undefined, reason ?? "")
+ const recipients = initiator && initiator !== me ? [initiator] : []
+ await sendProtocolEvent(rumor, recipients).catch(e => console.error("decline send failed", e))
+}
+
+// ── Reactive coordinator ─────────────────────────────────────────────────────────
+
+// Within-tick guard so the effect never launches the same async step twice before its
+// persisted flag lands. The persisted flag is the durable guard; this is the immediate one.
+const inFlight = new Set()
+
+// User consent, populated by acceptResharing. The shared SessionProgress type has no
+// `accepted` flag (it is DKG/signing-owned in secrets.ts), so resharing tracks consent in
+// memory here, mirroring the sibling DKG engine which treats `sentRound1` as the accept
+// signal. A contributor who already broadcast round 1 (`sentRound1`) is implicitly accepted,
+// so a reload mid-rotation resumes round 2/finalize without re-accepting; a new-only member
+// who reloads before finalize simply re-accepts from the inbox. The coordinator never
+// reshares a shard or saves a new shard / confirms without consent.
+const acceptedSessions = new Set()
+
+function isAccepted(s: ResharingSessionView, progress: ReturnType): boolean {
+ return acceptedSessions.has(s.proposalId) || progress.sentRound1 === true
+}
+
+let started = false
+
+export function startResharing(): void {
+ if (started) { return }
+ started = true
+
+ createEffect(() => {
+ const me = pubkey.get()
+ if (!me) { return }
+ for (const s of resharingSessions()) {
+ if (s.aborted || s.declined) { continue }
+ void advance(s, me)
+ }
+ })
+}
+
+async function advance(s: ResharingSessionView, me: Hex): Promise {
+ const progress = getProgress(s.proposalId)
+ if (progress.declined || progress.aborted) { return }
+
+ // 5.0 — Equivocation / abort check (run FIRST, every tick).
+ if (detectEquivocation(s)) {
+ patchProgress(s.proposalId, { aborted: true })
+ toast.error("Rotation aborted: conflicting confirmations")
+ return
+ }
+
+ // Gate every action on explicit user consent — round-1 broadcast IS the on-wire accept
+ // signal, and a new-only member must consent before joining the rotated quorum.
+ if (!isAccepted(s, progress)) { return }
+
+ // ── Derive index sets (always from the proposal's current member set, so contributors
+ // and new-only members agree). dkgCommitsByPk keys ARE the current member pubkeys.
+ const currentMembers = assignIndices(Object.keys(s.dkgCommitsByPk))
+ const currentIndexByPubkey = new Map(currentMembers.map(m => [m.pubkey, m.index]))
+
+ // originalCommitments: Record — used to verify each contributor's shard.
+ const originalCommitments: Record = {}
+ for (const m of currentMembers) {
+ originalCommitments[m.index] = s.dkgCommitsByPk[m.pubkey]
+ }
+
+ // contributorSet: original DKG indices of S, sorted (passed to lagrangeCoeff in protocol.ts).
+ const contributorSet = s.contributors
+ .map(pk => currentIndexByPubkey.get(pk))
+ .filter((i): i is number => i !== undefined)
+ .sort((a, b) => a - b)
+
+ const newMemberMembers = assignIndices(s.newMembers)
+ const myNewIndex = newMemberMembers.find(m => m.pubkey === me)?.index
+ const myOriginalIndex = currentIndexByPubkey.get(me)
+
+ // ── STEP A — Contributor Round 1 (kind 7055) ──────────────────────────────────
+ if (s.iAmContributor && !progress.sentRound1) {
+ const key = `${s.proposalId}:round1`
+ if (!inFlight.has(key)) {
+ inFlight.add(key)
+ try {
+ if (myOriginalIndex === undefined) {
+ throw new Error("Missing original index for contributor")
+ }
+ const shard = await loadShard(s.quorumPubkey)
+ if (shard === undefined) {
+ // Recoverable: the user may re-import their shard. Do not abort.
+ toast.error("Cannot reshare: your shard for this quorum is missing")
+ return
+ }
+ const { rumor, polynomial } = resharingRound1(
+ me,
+ s.proposalId,
+ s.quorumPubkey,
+ contributorSet,
+ myOriginalIndex,
+ shard,
+ s.newThreshold,
+ )
+
+ // Persist the polynomial + mark sentRound1 BEFORE network IO so a re-entrant tick skips,
+ // and a reload resumes round 2 with the SAME polynomial (re-sampling would break peers'
+ // verifyShare against the already-broadcast commitments).
+ patchProgress(s.proposalId, {
+ sentRound1: true,
+ encryptedPoly: await encryptPoly(polynomial),
+ ownIndex: myOriginalIndex,
+ })
+
+ const recipients = s.newMembers.filter(pk => pk !== me)
+ await sendProtocolEvent(rumor, recipients)
+ } catch (e) {
+ console.error("resharing round 1 failed", e)
+ } finally {
+ inFlight.delete(key)
+ }
+ }
+ return
+ }
+
+ // ── STEP B — Verify peers' Round 1 (precondition for round 2 / finalize) ──────
+ // Require every contributor's verified round 1 before proceeding.
+ const allContributorsPresent = s.contributors.every(c => s.round1[c])
+ for (const c of s.contributors) {
+ const data = s.round1[c]
+ if (!data) { continue }
+ const ci = currentIndexByPubkey.get(c)
+ if (ci === undefined) { continue }
+ const ok = verifyResharingRound1(
+ s.proposalId,
+ c,
+ ci,
+ contributorSet,
+ data.commitments,
+ data.proof,
+ originalCommitments,
+ )
+ if (!ok) {
+ patchProgress(s.proposalId, { aborted: true })
+ toast.error(`Rotation aborted: invalid commitment from ${c.slice(0, 8)}…`)
+ return
+ }
+ }
+ if (!allContributorsPresent) { return } // wait for more round-1 events
+
+ // ── STEP C — Contributor Round 2 (kind 7056, per recipient) ───────────────────
+ if (s.iAmContributor && progress.sentRound1 && !progress.sentRound2) {
+ const key = `${s.proposalId}:round2`
+ if (!inFlight.has(key)) {
+ inFlight.add(key)
+ try {
+ if (!progress.encryptedPoly) {
+ // Corruption: round 1 sent but polynomial lost — cannot reproduce shares.
+ patchProgress(s.proposalId, { aborted: true })
+ toast.error("Rotation aborted: lost resharing polynomial")
+ return
+ }
+ const poly = await decryptPoly(progress.encryptedPoly)
+
+ // Mark sentRound2 before dispatching so a re-entrant tick skips.
+ patchProgress(s.proposalId, { sentRound2: true })
+
+ for (const m of newMemberMembers) {
+ const rumor = resharingRound2(me, s.proposalId, s.quorumPubkey, poly, m.index)
+ if (m.pubkey === me) {
+ // Store our own self-share locally so finalize sees a complete shares map.
+ repository.publish(rumor)
+ } else {
+ await deliverTo(m.pubkey, rumor).catch(e => console.error("round 2 deliver failed", m.pubkey, e))
+ }
+ }
+ } catch (e) {
+ console.error("resharing round 2 failed", e)
+ } finally {
+ inFlight.delete(key)
+ }
+ }
+ // fall through: a retained member may finalize once shares arrive
+ }
+
+ // ── STEP D — New-member Finalize (kind 7057) ──────────────────────────────────
+ if (s.iAmNewMember && !progress.finalized && !progress.sentConfirm) {
+ // Need a share from EVERY contributor (including our own self-share if retained).
+ const haveAllShares =
+ s.contributors.length > 0 &&
+ s.contributors.every(c => {
+ const ci = currentIndexByPubkey.get(c)
+ return ci !== undefined && s.round2[ci] !== undefined
+ })
+
+ if (myNewIndex !== undefined && haveAllShares) {
+ const key = `${s.proposalId}:finalize`
+ if (!inFlight.has(key)) {
+ inFlight.add(key)
+ try {
+ // contributorCommitments (the Dᵢ from 7055), keyed by original contributor index.
+ const contributorCommitments: Record = {}
+ for (const c of s.contributors) {
+ const ci = currentIndexByPubkey.get(c)
+ const data = s.round1[c]
+ if (ci === undefined || !data) { continue }
+ contributorCommitments[ci] = data.commitments
+ }
+
+ let result
+ try {
+ result = resharingFinalize(
+ me,
+ s.proposalId,
+ s.quorumPubkey,
+ myNewIndex,
+ s.newMembers,
+ s.newThreshold,
+ contributorCommitments,
+ s.round2,
+ )
+ } catch (e) {
+ patchProgress(s.proposalId, { aborted: true })
+ toast.error("Rotation aborted: finalization failed")
+ console.error("resharing finalize failed", e)
+ return
+ }
+
+ // Persist the new quorum state, then append this rotation to the record.
+ await saveQuorum(result.state)
+ appendRotationRecord(s.quorumPubkey, s.proposalId)
+
+ // Mark finalized + sentConfirm BEFORE network IO.
+ patchProgress(s.proposalId, {
+ finalized: true,
+ sentConfirm: true,
+ ownIndex: myNewIndex,
+ })
+
+ const recipients = s.newMembers.filter(pk => pk !== me)
+ await sendProtocolEvent(result.rumor, recipients)
+ } catch (e) {
+ console.error("resharing finalize/confirm failed", e)
+ } finally {
+ inFlight.delete(key)
+ }
+ }
+ }
+ return
+ }
+
+ // ── STEP E — Completion detection (contributor-only, read-only latch) ─────────
+ // A contributor who is NOT a new member holds no new shard; latch `finalized` purely
+ // for UI/idempotency once a full MATCHING confirmation set exists, so the session view
+ // shows complete and stops re-evaluating. Never call saveQuorum for them.
+ if (s.iAmContributor && !s.iAmNewMember && !progress.finalized) {
+ if (hasCompleteConfirmation(s)) {
+ patchProgress(s.proposalId, { finalized: true })
+ }
+ }
+}
+
+// ── Coordinator helpers ──────────────────────────────────────────────────────────
+
+/** True iff two kind 7057 confirmations diverge on (transcript, quorum) — abort trigger. */
+function detectEquivocation(s: ResharingSessionView): boolean {
+ const pairs = new Set()
+ for (const c of s.confirms) {
+ if (c.transcript === undefined && c.quorum === undefined) { continue }
+ pairs.add(`${c.transcript ?? ""}|${c.quorum ?? ""}`)
+ }
+ return pairs.size > 1
+}
+
+/** True once all n' new members have confirmed with a single matching (transcript, quorum). */
+function hasCompleteConfirmation(s: ResharingSessionView): boolean {
+ const latest = new Map()
+ for (const c of s.confirms) {
+ latest.set(c.pubkey, { quorum: c.quorum, transcript: c.transcript })
+ }
+ const newMemberSet = new Set(s.newMembers)
+ const relevant = [...latest.entries()].filter(([pk]) => newMemberSet.has(pk))
+ if (relevant.length < s.newMembers.length) { return false }
+ const pairs = new Set(relevant.map(([, v]) => `${v.transcript ?? ""}|${v.quorum ?? ""}`))
+ return pairs.size === 1
+}
+
+/**
+ * Append a completed rotation's proposal id to the quorum record (deduped). saveQuorum
+ * upserts the record but preserves the EXISTING rotationRecords; this appends the new
+ * rotation id afterwards so rotation history accrues. Idempotent: a repeat finalize won't
+ * append twice.
+ */
+function appendRotationRecord(quorumPubkey: string, proposalId: string): void {
+ const rec = getQuorumRecord(quorumPubkey)
+ if (!rec) { return }
+ if (rec.rotationRecords.includes(proposalId)) { return }
+ const updated: QuorumRecord = { ...rec, rotationRecords: [...rec.rotationRecords, proposalId] }
+ const all = readQuora()
+ quora.set([...all.filter(q => q.quorumPubkey !== quorumPubkey), updated])
+}
+
+function readQuora(): QuorumRecord[] {
+ let value: QuorumRecord[] = []
+ quora.subscribe(v => { value = v })()
+ return value
+}
diff --git a/src/engine/secrets.ts b/src/engine/secrets.ts
new file mode 100644
index 0000000..230d4b3
--- /dev/null
+++ b/src/engine/secrets.ts
@@ -0,0 +1,132 @@
+import type { Readable } from "svelte/store"
+import { synced, localStorageProvider } from "@welshman/store"
+import { pubkey } from "@welshman/app"
+import { encryptShard, decryptShard } from "../models"
+import type { QuorumRecord, ShardRecord } from "../models"
+import type { QuorumState, SigningNonces } from "../protocol"
+
+// ── Synchronous reads ─────────────────────────────────────────────────────────
+// `synced()` returns a plain svelte writable: it has `.set`/`.subscribe`/`.ready`
+// but no `.get()`. Read the current value synchronously the same way the rest of
+// the app does (svelte's subscribe-fires-immediately contract), then unsubscribe.
+function read(store: Readable): T {
+ let value!: T
+ store.subscribe(v => { value = v })()
+ return value
+}
+
+// ── Per-session progress + encrypted polynomial ───────────────────────────────
+// Keyed by session id (inviteId / proposalId). Progress flags make every coordinator
+// send idempotent: a flag is set the moment a send is (optimistically) initiated, so
+// re-running the coordinator on the next repository change never repeats it.
+export type SessionProgress = {
+ encryptedPoly?: string
+ ownIndex?: number
+ sentRound1?: boolean
+ sentRound2?: boolean
+ sentConfirm?: boolean
+ finalized?: boolean
+ declined?: boolean
+ aborted?: boolean
+ // Our own finalized transcript / quorum pubkey, for local-vs-peer equivocation detection.
+ myTranscript?: string
+ myQuorumPubkey?: string
+}
+
+export const sessionProgress = synced>({
+ key: "nq:session-progress",
+ storage: localStorageProvider,
+ defaultValue: {},
+})
+
+export function getProgress(id: string): SessionProgress {
+ return read(sessionProgress)[id] ?? {}
+}
+
+export function patchProgress(id: string, patch: Partial): void {
+ const all = read(sessionProgress)
+ sessionProgress.set({ ...all, [id]: { ...all[id], ...patch } })
+}
+
+// ── Completed quora (public) + this member's encrypted shard records ───────────
+export const quora = synced({
+ key: "nq:quora",
+ storage: localStorageProvider,
+ defaultValue: [],
+})
+
+export const shardRecords = synced>({
+ key: "nq:shards",
+ storage: localStorageProvider,
+ defaultValue: {},
+})
+
+/**
+ * Persist a finalized quorum: split a QuorumState into a public QuorumRecord (no secret
+ * material) and an encrypted ShardRecord (our shard, NIP-44 self-encrypted at rest).
+ * Our participant index is recovered from `state.members` by our own pubkey; the shard
+ * record is keyed by quorum pubkey, so an existing record (e.g. from a prior rotation) is
+ * replaced. Rotation history on the public record is preserved across re-saves.
+ */
+export async function saveQuorum(state: QuorumState): Promise {
+ const me = pubkey.get()
+ const ownIndex = state.members.find(m => m.pubkey === me)?.index ?? state.members[0]?.index ?? 1
+
+ const record: QuorumRecord = {
+ quorumPubkey: state.quorumPubkey,
+ members: state.members,
+ threshold: state.threshold,
+ commitments: state.commitments,
+ rotationRecords: getQuorumRecord(state.quorumPubkey)?.rotationRecords ?? [],
+ }
+
+ const existing = read(quora)
+ quora.set([...existing.filter(q => q.quorumPubkey !== state.quorumPubkey), record])
+
+ const shard: ShardRecord = {
+ quorumPubkey: state.quorumPubkey,
+ index: ownIndex,
+ verificationShare: state.verificationShare,
+ encryptedShard: await encryptShard(state.shard),
+ }
+
+ const records = read(shardRecords)
+ shardRecords.set({ ...records, [state.quorumPubkey]: shard })
+}
+
+export function getQuorumRecord(quorumPubkey: string): QuorumRecord | undefined {
+ return read(quora).find(q => q.quorumPubkey === quorumPubkey)
+}
+
+/** Decrypt and return this member's shard for a quorum (or undefined). */
+export async function loadShard(quorumPubkey: string): Promise {
+ const record = read(shardRecords)[quorumPubkey]
+ if (!record) { return undefined }
+ try {
+ return await decryptShard(record.encryptedShard)
+ } catch (e) {
+ console.error("Failed to decrypt shard", quorumPubkey, e)
+ return undefined
+ }
+}
+
+export function getShardRecord(quorumPubkey: string): ShardRecord | undefined {
+ return read(shardRecords)[quorumPubkey]
+}
+
+// ── Signing nonces — IN MEMORY ONLY, NEVER persisted ──────────────────────────
+// Reusing (d,e) across sessions leaks the shard (PROTOCOL.md Security Notes), so these
+// live only in this Map and are dropped when the session ends or the page reloads.
+const nonces = new Map()
+
+export function setNonces(requestId: string, n: SigningNonces): void {
+ nonces.set(requestId, n)
+}
+
+export function getNonces(requestId: string): SigningNonces | undefined {
+ return nonces.get(requestId)
+}
+
+export function clearNonces(requestId: string): void {
+ nonces.delete(requestId)
+}
diff --git a/src/engine/signing.ts b/src/engine/signing.ts
new file mode 100644
index 0000000..f04cded
--- /dev/null
+++ b/src/engine/signing.ts
@@ -0,0 +1,603 @@
+import { createEffect, createSignal } from "solid-js"
+import { toast } from "solid-toast"
+import { hexToBytes } from "@noble/curves-v2/utils.js"
+import { pubkey, repository, publishThunk, getPubkeyRelays, loadRelayList } from "@welshman/app"
+import { RelayMode } from "@welshman/util"
+import type { OwnedEvent, TrustedEvent } from "@welshman/util"
+import {
+ createSignRequest,
+ signingRound1,
+ signingRound2,
+ aggregateSignature,
+ createDecline,
+ getHash,
+ assignIndices,
+} from "../protocol"
+import type { Hex } from "../protocol"
+import type { SigningSession } from "../models"
+import { sendProtocolEvent } from "./delivery"
+import {
+ getProgress,
+ patchProgress,
+ getQuorumRecord,
+ loadShard,
+ setNonces,
+ getNonces,
+ clearNonces,
+ sessionProgress,
+} from "./secrets"
+import {
+ signRequestEvents,
+ signNonceEvents,
+ signShareEvents,
+ declineEvents,
+ quorumOf,
+ noncesOf,
+ zOf,
+ signerIndicesOf,
+ reasonOf,
+ groupBySession,
+ latestByAuthor,
+} from "./events"
+
+// Active pubkey bridged to a Solid signal so the coordinator and session views re-run
+// reactively when the session is restored on login (pubkey.get() alone is not reactive,
+// which left the coordinator never advancing a request created before login hydrated).
+const [me, setMe] = createSignal("")
+pubkey.subscribe(pk => setMe(pk ?? ""))
+
+// Progress lives in localStorage (a svelte store), not a Solid signal, so a flag change
+// (e.g. `finalized` set by aggregation) must explicitly retrigger derivation. Bumping this
+// tick on every progress write makes signingSessions() recompute — without it the badge
+// stays on "round 2" after the signature is finalized instead of showing "Signed".
+const [progressTick, setProgressTick] = createSignal(0)
+sessionProgress.subscribe(() => setProgressTick(n => n + 1))
+
+// ─── Deterministic signing-set rule S ──────────────────────────────────────────
+//
+// PROTOCOL.md Round 2 has a privileged "coordinator" construct S and distribute
+// the finalized nonce list. We avoid a privileged party by deriving S purely from
+// the kind-7059 nonce events every signer already holds, so all signers compute the
+// IDENTICAL S without any extra distribution round:
+//
+// S = the ascending-sorted member indices of the FIRST `threshold` members
+// (ordered by member index, i.e. by sorted pubkey) who have broadcast a
+// kind-7059 nonce for this requestId.
+//
+// Concretely: take the dedup'd (newest-per-author) 7059 set, map each author to its
+// member index via assignIndices(quorum.members), sort the indices ascending, and
+// take the first `threshold` of them. Because every signer sees the same nonce events
+// in the same index order, they all derive the same S the instant >= threshold nonces
+// exist. Once chosen, S is frozen: surplus/late nonces never change it (we already
+// took the first t by index). allNonces for round-2/aggregation is built ONLY from the
+// members in S. No separate nonce-list distribution event is needed — S and the nonce
+// list are both deterministic from the 7059 events, satisfying PROTOCOL.md implicitly.
+function computeSigningSet(
+ nonceByAuthor: Map,
+ members: { pubkey: Hex; index: number }[],
+ threshold: number,
+): number[] {
+ const indexByPubkey = new Map(members.map(m => [m.pubkey, m.index]))
+ const indices: number[] = []
+ for (const pk of nonceByAuthor.keys()) {
+ const i = indexByPubkey.get(pk)
+ if (i !== undefined) { indices.push(i) }
+ }
+ indices.sort((a, b) => a - b)
+ return indices.slice(0, threshold)
+}
+
+// ─── Session-view derivation ────────────────────────────────────────────────────
+//
+// Pure function of the repository signals + persisted progress. A session is built
+// only for sign requests whose quorum we are a member of (we hold its QuorumRecord).
+function deriveSigningSessions(
+ mine: Hex,
+ requests: TrustedEvent[],
+ nonces: TrustedEvent[],
+ shares: TrustedEvent[],
+ declines: TrustedEvent[],
+): SigningSession[] {
+ const noncesBySession = groupBySession(nonces)
+ const sharesBySession = groupBySession(shares)
+ const declinesBySession = groupBySession(declines)
+
+ const sessions: SigningSession[] = []
+
+ for (const req of requests) {
+ const requestId = req.id
+ const quorumPubkey = quorumOf(req)
+ if (!quorumPubkey) { continue }
+
+ const quorum = getQuorumRecord(quorumPubkey)
+ if (!quorum) { continue }
+ // Only participate in quora we belong to.
+ if (!quorum.members.some(m => m.pubkey === mine)) { continue }
+
+ const p = getProgress(requestId)
+
+ // Parse the unsigned event from the request content; skip malformed requests.
+ let msgHex: Hex
+ try {
+ msgHex = getHash(JSON.parse(req.content) as OwnedEvent)
+ } catch {
+ continue
+ }
+
+ // Round-1 nonces (dedup newest-per-author), keyed by sender pubkey.
+ const nonceByAuthor = latestByAuthor(noncesBySession.get(requestId) ?? [])
+ const round1: Record = {}
+ for (const [pk, e] of nonceByAuthor) {
+ const n = noncesOf(e)
+ if (n) { round1[pk] = n }
+ }
+
+ const signingSet = computeSigningSet(nonceByAuthor, quorum.members, quorum.threshold)
+
+ // Round-2 signature shares (dedup newest-per-author), keyed by participant index.
+ // Only keep shares whose advertised signer-set matches the canonical S (mismatched
+ // sets are rejected — they belong to an incompatible attempt and would corrupt
+ // aggregation).
+ const canonical = JSON.stringify([...signingSet].sort((a, b) => a - b))
+ const shareByAuthor = latestByAuthor(sharesBySession.get(requestId) ?? [])
+ const indexByPubkey = new Map(quorum.members.map(m => [m.pubkey, m.index]))
+ const sharesByIndex: Record = {}
+ for (const [pk, e] of shareByAuthor) {
+ const idx = indexByPubkey.get(pk)
+ const z = zOf(e)
+ if (idx === undefined || z === undefined) { continue }
+ if (signingSet.length === quorum.threshold) {
+ const advertised = JSON.stringify([...signerIndicesOf(e)].sort((a, b) => a - b))
+ if (advertised !== canonical) { continue }
+ }
+ sharesByIndex[idx] = z
+ }
+
+ const declineMap: Record = {}
+ for (const e of declinesBySession.get(requestId) ?? []) {
+ declineMap[e.pubkey] = reasonOf(e)
+ }
+
+ // Phase precedence: complete -> round2 -> round1. SigningPhase has no terminal
+ // "aborted" member, so an aborted session reads as the most advanced phase it
+ // reached (the abort state itself is carried in persisted progress, surfaced via
+ // isSigningAborted()).
+ const myNonce = getNonces(requestId)
+ let phase: SigningSession["phase"]
+ if (p.finalized) {
+ phase = "complete"
+ } else if (p.sentRound1 && round1Count(round1) >= quorum.threshold) {
+ phase = "round2"
+ } else {
+ phase = "round1"
+ }
+
+ sessions.push({
+ requestId,
+ quorumPubkey,
+ msgHex,
+ phase,
+ signingSet,
+ myNonces: myNonce ? { D: myNonce.D, E: myNonce.E } : undefined,
+ round1,
+ shares: sharesByIndex,
+ declines: declineMap,
+ })
+ }
+
+ return sessions
+}
+
+function round1Count(round1: Record): number {
+ return Object.keys(round1).length
+}
+
+/** Reactive signing-session list for the UI. */
+export function signingSessions(): SigningSession[] {
+ progressTick() // re-derive when a progress flag (e.g. finalized) flips
+ const mine = me()
+ if (!mine) { return [] }
+ return deriveSigningSessions(
+ mine,
+ signRequestEvents(),
+ signNonceEvents(),
+ signShareEvents(),
+ declineEvents(),
+ )
+}
+
+// ─── User actions ───────────────────────────────────────────────────────────────
+
+/**
+ * Initiate a signing session. Normalizes the unsigned event so its NIP-01 id (== msg)
+ * is fixed and identical for every signer, broadcasts a kind-7058 request, and lets the
+ * coordinator drive the initiator's own nonce. Optimistic: never hard-fails on
+ * undeliverability. Returns the request id (the signing session identifier).
+ */
+export async function requestSignature(quorumPubkey: string, unsignedEvent: object): Promise {
+ const me = pubkey.get()
+ if (!me) { throw new Error("You must be logged in to request a signature") }
+
+ const quorum = getQuorumRecord(quorumPubkey)
+ if (!quorum) { throw new Error("Unknown quorum") }
+
+ const tpl = unsignedEvent as { kind?: number; tags?: string[][]; content?: string }
+ // Fix pubkey/created_at/kind/tags/content so the NIP-01 id is stable across signers.
+ const evt = {
+ pubkey: quorumPubkey,
+ created_at: Math.floor(Date.now() / 1000),
+ kind: tpl.kind ?? 1,
+ tags: tpl.tags ?? [],
+ content: tpl.content ?? "",
+ }
+
+ const req = createSignRequest(me, quorumPubkey, evt)
+ const others = quorum.members.map(m => m.pubkey).filter(pk => pk !== me)
+
+ try {
+ await sendProtocolEvent(req, others)
+ } catch (e) {
+ repository.publish(req)
+ toast.error(e instanceof Error ? e.message : "Failed to send sign request")
+ }
+
+ // The requester implicitly consents, so complete round 1 right away: issue our nonce so
+ // the session counts the initiator toward the threshold without a separate accept step.
+ // (The coordinator's ST0 is a fallback; this makes it deterministic and immediate.)
+ if (!getProgress(req.id).sentRound1) {
+ const { rumor, nonces } = signingRound1(me, req.id, quorumPubkey)
+ setNonces(req.id, nonces)
+ patchProgress(req.id, { sentRound1: true })
+ try {
+ await sendProtocolEvent(rumor, others)
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : "Failed to send nonce")
+ }
+ }
+
+ return req.id
+}
+
+/**
+ * Consent to sign: issue OUR round-1 nonce (kind 7059). Nonces are IN MEMORY ONLY
+ * (setNonces) and never persisted — reusing them across sessions leaks the shard.
+ * The coordinator advances us to round 2 once S is finalizable.
+ */
+export async function acceptSign(requestId: string): Promise {
+ const me = pubkey.get()
+ if (!me) { throw new Error("You must be logged in to sign") }
+
+ const p = getProgress(requestId)
+ if (p.sentRound1 || p.declined || p.aborted) { return }
+
+ const req = signRequestEvents().find(e => e.id === requestId)
+ if (!req) { return }
+ const quorumPubkey = quorumOf(req)
+ if (!quorumPubkey) { return }
+ const quorum = getQuorumRecord(quorumPubkey)
+ if (!quorum) { return }
+
+ const { rumor, nonces } = signingRound1(me, requestId, quorumPubkey)
+ setNonces(requestId, nonces)
+ patchProgress(requestId, { sentRound1: true })
+
+ const others = quorum.members.map(m => m.pubkey).filter(pk => pk !== me)
+ try {
+ await sendProtocolEvent(rumor, others)
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : "Failed to send nonce")
+ }
+}
+
+/** Decline a sign request — informational, sent only to the initiator. */
+export async function declineSign(requestId: string, reason?: string): Promise {
+ const me = pubkey.get()
+ if (!me) { throw new Error("You must be logged in to decline") }
+
+ const p = getProgress(requestId)
+ if (p.sentRound1 || p.declined) { return }
+
+ const req = signRequestEvents().find(e => e.id === requestId)
+ if (!req) { return }
+ const quorumPubkey = quorumOf(req)
+
+ const rumor = createDecline(me, requestId, quorumPubkey, reason)
+ patchProgress(requestId, { declined: true })
+
+ try {
+ await sendProtocolEvent(rumor, [req.pubkey])
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : "Failed to send decline")
+ }
+}
+
+// ─── Coordinator ────────────────────────────────────────────────────────────────
+//
+// Tracks the signed nostr event id produced by aggregation, surfaced via
+// publishedSignature(). Kept in-module (the SigningSession model has no field for it)
+// and best-effort only — the signed event is also published to the repository.
+const signedEventIds = new Map()
+
+/** The published signed-event id for a completed signing session, if any. */
+export function publishedSignature(requestId: string): string | undefined {
+ return signedEventIds.get(requestId)
+}
+
+/** Whether a signing session has aborted (equivocating signer-sets or a crypto failure). */
+export function isSigningAborted(requestId: string): boolean {
+ return getProgress(requestId).aborted === true
+}
+
+// Per-session snapshot the coordinator advances. Captured synchronously inside the
+// reactive effect; async send closures read only from this (never reactive signals).
+type Snapshot = {
+ requestId: string
+ quorumPubkey: Hex
+ initiatorPubkey: Hex
+ msgHex: Hex
+ unsignedEvent: OwnedEvent
+ threshold: number
+ members: { pubkey: Hex; index: number }[]
+ signingSet: number[]
+ // index -> {D,E} for every member that broadcast a nonce (used to build allNonces for S)
+ noncesByIndex: Record
+ // index -> z for every share whose signer-set matches canonical S
+ sharesByIndex: Record
+ // every 7060 for the session, for the signer-set agreement check
+ shareEvents: TrustedEvent[]
+}
+
+function buildSnapshot(
+ mine: Hex,
+ req: TrustedEvent,
+ noncesBySession: Map,
+ sharesBySession: Map,
+): Snapshot | undefined {
+ const requestId = req.id
+ const quorumPubkey = quorumOf(req)
+ if (!quorumPubkey) { return undefined }
+ const quorum = getQuorumRecord(quorumPubkey)
+ if (!quorum) { return undefined }
+ if (!quorum.members.some(m => m.pubkey === mine)) { return undefined }
+
+ let unsignedEvent: OwnedEvent
+ let msgHex: Hex
+ try {
+ unsignedEvent = JSON.parse(req.content) as OwnedEvent
+ msgHex = getHash(unsignedEvent)
+ } catch {
+ return undefined
+ }
+
+ const indexByPubkey = new Map(quorum.members.map(m => [m.pubkey, m.index]))
+
+ const nonceByAuthor = latestByAuthor(noncesBySession.get(requestId) ?? [])
+ const signingSet = computeSigningSet(nonceByAuthor, quorum.members, quorum.threshold)
+ const canonical = JSON.stringify([...signingSet].sort((a, b) => a - b))
+
+ const noncesByIndex: Record = {}
+ for (const [pk, e] of nonceByAuthor) {
+ const idx = indexByPubkey.get(pk)
+ const n = noncesOf(e)
+ if (idx !== undefined && n) { noncesByIndex[idx] = n }
+ }
+
+ const shareEvents = [...latestByAuthor(sharesBySession.get(requestId) ?? []).values()]
+ const sharesByIndex: Record = {}
+ for (const e of shareEvents) {
+ const idx = indexByPubkey.get(e.pubkey)
+ const z = zOf(e)
+ if (idx === undefined || z === undefined) { continue }
+ if (signingSet.length === quorum.threshold) {
+ const advertised = JSON.stringify([...signerIndicesOf(e)].sort((a, b) => a - b))
+ if (advertised !== canonical) { continue }
+ }
+ sharesByIndex[idx] = z
+ }
+
+ return {
+ requestId,
+ quorumPubkey,
+ initiatorPubkey: req.pubkey,
+ msgHex,
+ unsignedEvent,
+ threshold: quorum.threshold,
+ members: quorum.members,
+ signingSet,
+ noncesByIndex,
+ sharesByIndex,
+ shareEvents,
+ }
+}
+
+// Decide the next action for one session and (idempotently) push guarded async tasks.
+// Guard flags are set synchronously BEFORE the async send is queued, so the re-entrant
+// run triggered by our own repository.publish is a no-op.
+function advanceSigning(mine: Hex, s: Snapshot, tasks: Array<() => Promise>): void {
+ const p = getProgress(s.requestId)
+ if (p.declined || p.aborted) { return }
+
+ const ownIndex = s.members.find(m => m.pubkey === mine)?.index
+ if (ownIndex === undefined) { return }
+
+ const ourNonceInRepo = s.noncesByIndex[ownIndex] !== undefined
+
+ // Reconcile a lost progress flag with reality: if our own 7059 is already in the
+ // repository (e.g. flag dropped on reload) record that we sent round 1, so the phase
+ // derivation is accurate. The in-memory nonce is gone after reload, so ST1 will then
+ // correctly stall rather than reuse — and never re-broadcast a duplicate nonce.
+ if (ourNonceInRepo && !p.sentRound1) {
+ patchProgress(s.requestId, { sentRound1: true })
+ }
+
+ // ST0 — initiator auto-issues its nonce (implicit consent by requesting). All other
+ // members issue a nonce only via explicit acceptSign.
+ if (mine === s.initiatorPubkey && !p.sentRound1 && !ourNonceInRepo) {
+ const quorumPubkey = s.quorumPubkey
+ const others = s.members.map(m => m.pubkey).filter(pk => pk !== mine)
+ patchProgress(s.requestId, { sentRound1: true })
+ tasks.push(async () => {
+ const { rumor, nonces } = signingRound1(mine, s.requestId, quorumPubkey)
+ setNonces(s.requestId, nonces)
+ await sendProtocolEvent(rumor, others)
+ })
+ return
+ }
+
+ // We have committed to round 1 if the flag is set OR our nonce is already in the repo
+ // (the reconcile above may have just set the flag, so don't trust the stale `p`).
+ const sentRound1 = p.sentRound1 || ourNonceInRepo
+
+ // ST1 — round 2: compute our signature share once S is finalizable and we are in S.
+ if (sentRound1 && !p.sentRound2) {
+ const ourShareInRepo = s.sharesByIndex[ownIndex] !== undefined
+ const sFinalizable = s.signingSet.length === s.threshold
+ const inS = s.signingSet.includes(ownIndex)
+ const myNonce = getNonces(s.requestId)
+
+ if (sFinalizable && inS && !ourShareInRepo) {
+ if (!myNonce) {
+ // Nonce lost (e.g. page reload mid-signing). We cannot reconstruct it (never
+ // persisted, H5) and must not issue a fresh one for a request we already
+ // broadcast a nonce for. Surface and skip — the initiator restarts.
+ return
+ }
+ const signingSet = [...s.signingSet]
+ const allNonces: Record = {}
+ for (const i of signingSet) {
+ const n = s.noncesByIndex[i]
+ if (!n) { return } // missing a nonce for a member of S — wait
+ allNonces[i] = n
+ }
+ const quorumPubkey = s.quorumPubkey
+ const msg = hexToBytes(s.msgHex)
+ const others = s.members.map(m => m.pubkey).filter(pk => pk !== mine)
+
+ patchProgress(s.requestId, { sentRound2: true })
+ tasks.push(async () => {
+ const shard = await loadShard(quorumPubkey)
+ if (shard === undefined) {
+ toast.error("Missing shard — cannot sign")
+ return
+ }
+ const { rumor } = signingRound2(
+ mine, s.requestId, quorumPubkey, msg, signingSet, ownIndex, shard, myNonce, allNonces,
+ )
+ await sendProtocolEvent(rumor, others)
+ })
+ return
+ }
+ }
+
+ // ST2 — aggregate + publish. Any member runs this once all of S's shares are in; the
+ // result is deterministic, so cross-client duplicate publishes are harmless (same id).
+ if (!p.finalized && s.signingSet.length === s.threshold) {
+ // Signer-set agreement (PROTOCOL.md Round 2 / Aggregation): if ANY received 7060
+ // advertises a signer set other than the canonical S, abort the session. This must
+ // run BEFORE the haveAllShares gate: a conflicting signer-set share is filtered out
+ // of sharesByIndex, so haveAllShares would never become true and the session would
+ // silently stall forever instead of aborting as the protocol requires.
+ const canonical = JSON.stringify([...s.signingSet].sort((a, b) => a - b))
+ for (const e of s.shareEvents) {
+ const advertised = JSON.stringify([...signerIndicesOf(e)].sort((a, b) => a - b))
+ if (advertised !== canonical) {
+ patchProgress(s.requestId, { aborted: true })
+ return
+ }
+ }
+
+ const haveAllShares = s.signingSet.every(i => s.sharesByIndex[i] !== undefined)
+ if (!haveAllShares) { return }
+
+ const quorum = getQuorumRecord(s.quorumPubkey)
+ if (!quorum) { return }
+
+ const signingSet = [...s.signingSet]
+ const allNonces: Record = {}
+ const shares: Record = {}
+ for (const i of signingSet) {
+ const n = s.noncesByIndex[i]
+ const z = s.sharesByIndex[i]
+ if (!n || z === undefined) { return }
+ allNonces[i] = n
+ shares[i] = BigInt("0x" + z) // protocol.ts does not export fromHex
+ }
+
+ const quorumPubkey = s.quorumPubkey
+ const msg = hexToBytes(s.msgHex)
+ const commitments = quorum.commitments
+ const unsignedEvent = s.unsignedEvent
+
+ patchProgress(s.requestId, { finalized: true })
+ tasks.push(async () => {
+ let sigHex: string
+ try {
+ sigHex = aggregateSignature(quorumPubkey, msg, signingSet, allNonces, shares, commitments)
+ } catch (e) {
+ patchProgress(s.requestId, { aborted: true, finalized: false })
+ toast.error(e instanceof Error ? e.message : "Signature aggregation failed")
+ return
+ }
+
+ // Attach the aggregated BIP-340 signature to the unsigned event from the 7058
+ // content. The event's id == msg (the same canonical hash all signers used).
+ const signed: TrustedEvent = {
+ ...unsignedEvent,
+ pubkey: quorumPubkey,
+ id: getHash(unsignedEvent),
+ sig: sigHex,
+ }
+ signedEventIds.set(s.requestId, signed.id)
+ clearNonces(s.requestId)
+
+ repository.publish(signed)
+ try {
+ // Publish the quorum's freshly signed event to its outbox (kind-10002 write) relays.
+ // Every party that aggregates does this, so it lands even if some are offline. If the
+ // quorum has no relay list, this resolves to no relays (the request form warns about that).
+ await loadRelayList(quorumPubkey).catch(() => undefined)
+ publishThunk({ event: signed, relays: getPubkeyRelays(quorumPubkey, RelayMode.Write) })
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : "Failed to publish signed event")
+ }
+ })
+ }
+}
+
+/**
+ * Start the signing coordinator. Reactive over the repository signals; on every change
+ * it re-derives each session I participate in and advances it idempotently. Guard flags
+ * are set synchronously before async sends, and every transition is double-guarded by a
+ * repository-presence check, so a lost progress flag (e.g. on boot before hydration)
+ * never causes a duplicate send.
+ */
+export function startSigning(): void {
+ createEffect(() => {
+ progressTick() // re-advance when a progress flag flips, not only on new events
+ const mine = me()
+ if (!mine) { return }
+
+ // Read reactive signals synchronously so Solid tracks them.
+ const requests = signRequestEvents()
+ const nonces = signNonceEvents()
+ const shares = signShareEvents()
+
+ const noncesBySession = groupBySession(nonces)
+ const sharesBySession = groupBySession(shares)
+
+ const tasks: Array<() => Promise> = []
+ for (const req of requests) {
+ const s = buildSnapshot(mine, req, noncesBySession, sharesBySession)
+ if (!s) { continue }
+ advanceSigning(mine, s, tasks)
+ }
+
+ if (tasks.length) {
+ queueMicrotask(() => {
+ for (const t of tasks) {
+ void t().catch(e => console.error("signing task failed", e))
+ }
+ })
+ }
+ })
+}
diff --git a/src/lib/media.ts b/src/lib/media.ts
new file mode 100644
index 0000000..8ab33dd
--- /dev/null
+++ b/src/lib/media.ts
@@ -0,0 +1,11 @@
+import { createSignal } from "solid-js"
+
+// Reactive match for Tailwind's `md` breakpoint (≥ 768px). The layout renders both the
+// desktop and mobile trees simultaneously (toggled with CSS), so components that behave
+// differently per breakpoint — e.g. only marking a tab read when actually on screen —
+// need this to tell which tree is visible. Module-level singleton, app-lifetime.
+const query = window.matchMedia("(min-width: 768px)")
+const [isDesktop, setIsDesktop] = createSignal(query.matches)
+query.addEventListener("change", e => setIsDesktop(e.matches))
+
+export { isDesktop }
diff --git a/src/models.ts b/src/models.ts
index e08e3d5..a3974ac 100644
--- a/src/models.ts
+++ b/src/models.ts
@@ -79,6 +79,12 @@ export type DkgSession = {
round2: Record
/** Members who declined, keyed by pubkey, value is their optional reason */
declines: Record
+ /** Set when the session was aborted (equivocation or a crypto-verification failure) */
+ aborted?: boolean
+ /** True once we have locally finalized and saved this quorum (usable for signing) */
+ complete?: boolean
+ /** Count of matching kind 7053 confirmations received (ours + peers') */
+ confirmed?: number
}
// ── Resharing session ─────────────────────────────────────────────────────────
diff --git a/src/nostr.ts b/src/nostr.ts
index cade01d..23e4036 100644
--- a/src/nostr.ts
+++ b/src/nostr.ts
@@ -3,15 +3,21 @@ import { request } from "@welshman/net"
type Subscription = { unsubscribe(): void }
/**
- * Subscribe to the user's NIP-59 inbox (kind 1059 gift wraps tagged with the app topic).
+ * Subscribe to the user's NIP-59 inbox (kind 1059 gift wraps addressed to them).
* With shouldUnwrap enabled in boot.ts, incoming wraps are auto-unwrapped and the inner
- * protocol rumor is stored in the repository — no manual unwrap/persist needed here.
+ * rumor is stored in the repository — no manual unwrap/persist needed here.
+ *
+ * We intentionally do NOT filter on the protocol's ["t","b7ed"] topic tag: protocol wraps
+ * carry it (for spec-compliant filtering by other implementations) but NIP-17 quorum chat
+ * (kind 14) wraps do not. Filtering by topic would silently drop all inbound chat. The
+ * standard NIP-17 inbox is unfiltered by topic; we follow that and let the engine/chat
+ * derivations select the rumor kinds they care about from the repository.
*/
export function subscribeInbox(relays: string[], pubkey: string): Subscription {
const ctrl = new AbortController()
request({
relays,
- filters: [{ kinds: [1059], "#p": [pubkey], "#t": ["b7ed"] }],
+ filters: [{ kinds: [1059], "#p": [pubkey] }],
signal: ctrl.signal,
})
return { unsubscribe: () => ctrl.abort() }
diff --git a/src/quorum.ts b/src/quorum.ts
deleted file mode 100644
index 7a40a57..0000000
--- a/src/quorum.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import {
- pubkey,
- publishThunk,
- getCompleteThunkUrls,
- getFailedThunkUrls,
-} from "@welshman/app"
-import { Router } from "@welshman/router"
-import { createInvite } from "./protocol"
-import { openQuorum, setDelivery } from "./store"
-
-/**
- * Create a quorum by publishing a kind 7050 DKG invite.
- *
- * This is optimistic: publishThunk writes the invite to the repository synchronously,
- * so the quorum shows up in the list immediately — whether or not any relay accepts it.
- * Delivery success/failure is reflected in the quorum's status, never blocks creation.
- *
- * Returns the invite id (the quorum's identifier until DKG completes).
- */
-export async function createQuorum(opts: {
- members: string[]
- threshold: number
- message?: string
-}): Promise {
- const pk = pubkey.get()
- if (!pk) { throw new Error("You must be logged in to create a quorum") }
-
- // The creator is always a member of their own quorum.
- const members = Array.from(new Set([pk, ...opts.members]))
- const invite = createInvite(pk, members, opts.threshold, opts.message ?? "")
- const inviteId = invite.id
-
- // Show it (and select it) right away, before the network round-trip.
- setDelivery(inviteId, "sending")
- openQuorum(inviteId)
-
- const relays = Router.get().FromUser().getUrls()
- const thunk = publishThunk({ event: invite, relays })
-
- // Resolve the delivery indicator without blocking the UI. The invite is already in
- // the repository, so the quorum stays visible no matter how this settles.
- let settled = false
- const finish = (status: "saved" | "failed") => {
- if (settled) { return }
- settled = true
- setDelivery(inviteId, status)
- }
-
- if (relays.length === 0) {
- finish("saved") // stored locally; there are no relays to deliver to
- } else {
- // "saved" once any relay accepts it; "failed" only if every relay rejects it
- // (which also covers a signing failure, where all relays are marked failed).
- thunk.subscribe(t => {
- if (getCompleteThunkUrls(t).length > 0) { finish("saved") }
- else if (getFailedThunkUrls(t).length >= relays.length) { finish("failed") }
- })
- }
-
- return inviteId
-}
diff --git a/src/store.ts b/src/store.ts
index 66ecd83..db7e8c5 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -15,6 +15,7 @@ import {
} from "@welshman/app"
import { subscribeInbox } from "./nostr"
import { assignIndices } from "./protocol"
+import { quora as completedQuora } from "./engine/secrets"
import type {
QuorumRecord,
DisplayedQuorum,
@@ -24,7 +25,7 @@ import type {
SigningSession,
} from "./models"
-export type View = "inbox" | "account" | { type: "quorum"; id: string; tab: "log" | "members" | "chat" }
+export type View = "home" | "account" | { type: "quorum"; id: string; tab: "log" | "members" | "chat" }
export function logout(): void {
// dropSession removes the session and its cached signer entirely; the sync()
@@ -34,7 +35,7 @@ export function logout(): void {
if (pk) { dropSession(pk) }
}
-export const [view, setView] = createSignal("inbox")
+export const [view, setView] = createSignal("home")
export function openQuorum(id: string): void {
setView({ type: "quorum", id, tab: "log" })
@@ -76,6 +77,12 @@ deriveEvents({ repository, filters: [{ kinds: [7051] }] }).subscribe(setRound1Ev
deriveEvents({ repository, filters: [{ kinds: [7061] }] }).subscribe(setDeclineEvents)
deriveEvents({ repository, filters: [{ kinds: [7053] }] }).subscribe(setCompleteEvents)
+// Finalized quora (carry our shard) persisted by the engine once a DKG or resharing
+// completes. Merged into displayedQuora below so a quorum keeps showing after its
+// invite events are pruned, and so quora we joined without holding the invite appear.
+const [finishedQuora, setFinishedQuora] = createSignal([])
+completedQuora.subscribe(setFinishedQuora)
+
function authorsByInviteId(events: TrustedEvent[]): Map> {
const m = new Map>()
for (const e of events) {
@@ -149,6 +156,29 @@ export const displayedQuora = createMemo(() => {
} satisfies DisplayedQuorum
})
+ // Merge in finalized quora from the engine. A completed quorum already surfaced via
+ // its invite (which now has a 7053) is skipped here so it shows once, as complete and
+ // selectable by inviteId; records with no local invite (e.g. we joined elsewhere, or
+ // the invite was pruned) are appended as standalone complete entries.
+ const shownPubkeys = new Set(list.map(q => q.quorumPubkey).filter(Boolean))
+ for (const record of finishedQuora()) {
+ if (shownPubkeys.has(record.quorumPubkey)) { continue }
+ shownPubkeys.add(record.quorumPubkey)
+ list.push({
+ id: record.quorumPubkey,
+ quorumPubkey: record.quorumPubkey,
+ inviteId: record.quorumPubkey,
+ members: record.members,
+ threshold: record.threshold,
+ complete: true,
+ status: "complete",
+ statusLabel: "Complete",
+ joined: record.members.length,
+ declined: 0,
+ createdAt: 0,
+ } satisfies DisplayedQuorum)
+ }
+
return list.sort((a, b) => b.createdAt - a.createdAt)
})
@@ -160,6 +190,16 @@ export const activeQuorum = createMemo(() => {
return undefined
})
+// Land on the first quorum instead of the empty home view — e.g. once quora hydrate on
+// load. Only fires while on "home", so it never overrides explicit navigation to a quorum
+// or the account page. When there are no quora, "home" stays (and shows the hero/CTA).
+createEffect(() => {
+ const list = displayedQuora()
+ if (view() === "home" && list.length > 0) {
+ openQuorum(list[0].id)
+ }
+})
+
// ── Inbox subscription ────────────────────────────────────────────────────────
// One active subscription, restarted when the active pubkey or the user's
// kind-10050 relay list changes. Subscribing to userMessagingRelayList makes it
@@ -203,7 +243,14 @@ userMessagingRelayList.subscribe($list => {
// Eagerly fetch relay lists and profiles for every member of every visible quorum,
// so their names render in the list and detail views.
createEffect(() => {
- const pubkeys = new Set(displayedQuora().flatMap(q => q.members.map(m => m.pubkey)))
+ const list = displayedQuora()
+ const pubkeys = new Set(list.flatMap(q => q.members.map(m => m.pubkey)))
+ // Include each established quorum's OWN pubkey so its profile + relay lists load in the
+ // background — needed to render the quorum's name and to publish its signed events to
+ // its outbox (kind-10002 write) relays.
+ for (const q of list) {
+ if (q.quorumPubkey) { pubkeys.add(q.quorumPubkey) }
+ }
for (const pk of pubkeys) {
loadRelayList(pk)
loadMessagingRelayList(pk)