Add signing engine, refine ui

This commit is contained in:
Jon Staab
2026-06-11 19:05:15 -07:00
parent 287e599753
commit a1ddb3bbd7
26 changed files with 3404 additions and 398 deletions
+40 -32
View File
@@ -10,8 +10,9 @@ import { ProposeSign } from "./components/forms/SigningForms"
import Avatar from "./components/Avatar"
import { displayProfile } from "@welshman/util"
import { useActivePubkey, useProfile } from "./hooks"
import { isDesktop } from "./lib/media"
function SidebarContent(props: { onNew: () => void }) {
function SidebarContent(props: { onNew: () => void; onNavigate?: () => void }) {
const pubkey = useActivePubkey()
const shortKey = () => `${pubkey().slice(0, 8)}${pubkey().slice(-4)}`
const profile = useProfile(pubkey)
@@ -19,28 +20,15 @@ function SidebarContent(props: { onNew: () => void }) {
return (
<div class="flex flex-col h-full">
<div class="px-3 pt-4 pb-2">
<button
class={`w-full text-left px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
view() === "inbox"
? "bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "text-gray-700 dark:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-700"
}`}
onClick={() => setView("inbox")}
>
Inbox
</button>
</div>
<div class="px-3 py-2 flex-1 overflow-y-auto">
<QuorumList onNew={props.onNew} />
<QuorumList onNew={props.onNew} onNavigate={props.onNavigate} />
</div>
<div class="border-t border-gray-200 dark:border-neutral-700 p-3">
<div class="flex items-center gap-2">
<button
class="flex items-center gap-2 flex-1 min-w-0 rounded-lg px-1 py-0.5 -ml-1 hover:bg-gray-100 dark:hover:bg-neutral-700 transition-colors text-left"
onClick={() => setView("account")}
onClick={() => { setView("account"); props.onNavigate?.() }}
>
<Avatar pubkey={pubkey()} />
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{name()}</p>
@@ -48,7 +36,7 @@ function SidebarContent(props: { onNew: () => void }) {
<button
class="p-1.5 rounded-md text-gray-400 hover:text-gray-600 dark:text-neutral-500 dark:hover:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-700 transition-colors"
title="Log out"
onClick={() => logout()}
onClick={() => { logout(); props.onNavigate?.() }}
>
<svg class="size-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h6a2 2 0 012 2v1" />
@@ -74,7 +62,7 @@ export default function Layout() {
<div class="px-4 py-4 border-b border-gray-200 dark:border-neutral-700">
<span class="text-base font-bold text-gray-900 dark:text-white">NQ</span>
</div>
<SidebarContent onNew={() => setShowProposeQuorum(true)} />
<SidebarContent onNew={() => setShowProposeQuorum(true)} onNavigate={() => setDrawerOpen(false)} />
</aside>
{/* Mobile drawer backdrop */}
@@ -98,19 +86,29 @@ export default function Layout() {
</svg>
</button>
</div>
<SidebarContent onNew={() => setShowProposeQuorum(true)} />
<SidebarContent onNew={() => setShowProposeQuorum(true)} onNavigate={() => setDrawerOpen(false)} />
</aside>
{/* Desktop: 3-column layout */}
<div class="hidden md:flex flex-1 min-w-0 overflow-hidden">
{/* Col 2: main content */}
<div class="flex-1 min-w-0 overflow-y-auto">
<Show when={view() === "inbox"}>
<div class="p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Inbox</h2>
<p class="text-sm text-gray-500 dark:text-neutral-400">
Pending invitations and requests will appear here.
</p>
<Show when={view() === "home"}>
<div class="flex items-center justify-center min-h-full p-6">
<div class="flex flex-col items-center text-center gap-4 max-w-sm">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Welcome to NQ</h1>
<p class="text-sm text-gray-500 dark:text-neutral-400">
FROST threshold multisig for Nostr keys. Create a quorum to share control of a
key with a group, where any threshold of members can sign.
</p>
<button
type="button"
class="px-5 py-2.5 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
onClick={() => setShowProposeQuorum(true)}
>
Create your first quorum
</button>
</div>
</div>
</Show>
<Show when={view() === "account"}>
@@ -131,7 +129,7 @@ export default function Layout() {
<span class="text-sm font-semibold text-gray-700 dark:text-neutral-300">Sessions</span>
</div>
<div class="flex-1 overflow-y-auto">
<QuorumChat />
<QuorumChat isVisible={isDesktop} />
</div>
</div>
</Show>
@@ -152,12 +150,22 @@ export default function Layout() {
</header>
<main class="flex-1 overflow-y-auto">
<Show when={view() === "inbox"}>
<div class="p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Inbox</h2>
<p class="text-sm text-gray-500 dark:text-neutral-400">
Pending invitations and requests will appear here.
</p>
<Show when={view() === "home"}>
<div class="flex items-center justify-center min-h-full p-6">
<div class="flex flex-col items-center text-center gap-4 max-w-sm">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Welcome to NQ</h1>
<p class="text-sm text-gray-500 dark:text-neutral-400">
FROST threshold multisig for Nostr keys. Create a quorum to share control of a
key with a group, where any threshold of members can sign.
</p>
<button
type="button"
class="px-5 py-2.5 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
onClick={() => setShowProposeQuorum(true)}
>
Create your first quorum
</button>
</div>
</div>
</Show>
<Show when={view() === "account"}>
+3
View File
@@ -2,6 +2,7 @@ import { createSignal, Show } from "solid-js"
import toast from "solid-toast"
import { getNip07, Nip07Signer } from "@welshman/signer"
import { loginWithNip01, loginWithNip07 } from "@welshman/app"
import { onLogin } from "./engine/notifications"
type Tab = "extension" | "nsec" | "nip46"
@@ -61,6 +62,7 @@ export default function Login() {
if (!getNip07()) { throw new Error("Extension not found") }
const pubkey = await new Nip07Signer().getPubkey()
loginWithNip07(pubkey)
onLogin()
} catch (e: any) {
toast.error(e?.message ?? "Extension not found")
} finally {
@@ -82,6 +84,7 @@ export default function Login() {
throw new Error("Invalid key")
}
loginWithNip01(secret)
onLogin()
} catch (e: any) {
toast.error(e?.message ?? "Invalid key")
} finally {
+18 -1
View File
@@ -1,7 +1,9 @@
import "@welshman/app" // side effects: wires pool → repository/tracker/router
import { shouldUnwrap } from "@welshman/app"
import { shouldUnwrap, pubkey, sessions } from "@welshman/app"
import { routerContext } from "@welshman/router"
import { sync, localStorageProvider } from "@welshman/store"
import { hydrateRepository, persistRepository } from "./storage"
import { startEngine } from "./engine"
const parseRelays = (v?: string): string[] =>
(v ?? "").split(",").map(s => s.trim()).filter(Boolean)
@@ -13,9 +15,24 @@ routerContext.getIndexerRelays = () => INDEXER_RELAYS
routerContext.getSearchRelays = () => SEARCH_RELAYS
routerContext.getDefaultRelays = () => INDEXER_RELAYS
// Persist login across reloads. @welshman/app keeps `sessions` (the keypairs/methods) and
// `pubkey` (the active one) in memory only; sync() hydrates these existing stores from
// localStorage on boot and writes every change back, so the active session and its derived
// signer are restored after a refresh. Kicked off early so the session is available ASAP
// (it resolves async; the UI shows Login until pubkey hydrates).
// Hydrate sessions first, then the active pubkey, so the derived session/signer resolve
// correctly the instant pubkey is restored (no window where pubkey is set but its session isn't).
void sync({ key: "nq:sessions", store: sessions, storage: localStorageProvider })
.then(() => sync({ key: "nq:pubkey", store: pubkey, storage: localStorageProvider }))
// Opt into NIP-59 gift-wrap unwrapping so inbox rumors are decrypted and stored automatically.
shouldUnwrap.set(true)
// Start the protocol engine's reactive coordinators (DKG, resharing, signing). They are
// idempotent and process already-hydrated events on their first run, so they are safe to
// start before hydration completes.
startEngine()
// Repository persistence: register the flush listener, then hydrate from disk.
persistRepository()
void hydrateRepository()
+145 -122
View File
@@ -1,132 +1,155 @@
import { For, Show, createMemo } from "solid-js"
import { activeQuorum, dkgSessions, resharingSessions, signingSessions } from "../store"
import type { DkgSession, ResharingSession, SigningSession } from "../models"
import { For, Show, createMemo, createSignal, createEffect } from "solid-js"
import type { TrustedEvent } from "@welshman/util"
import { toast } from "solid-toast"
import { activeQuorum } from "../store"
import { useProfileDisplay, useActivePubkey } from "../hooks"
import { chatMessages, sendChatMessage } from "../engine/chat"
import { markTabViewed } from "../engine/notifications"
import Avatar from "./Avatar"
export default function QuorumChat() {
const pendingDkg = createMemo<DkgSession[]>(() =>
dkgSessions.filter(s => s.phase === "round1")
)
const pendingSigning = createMemo<SigningSession[]>(() => {
const q = activeQuorum()
return signingSessions.filter(s => s.phase === "round1" && s.quorumPubkey === q?.quorumPubkey)
function formatTime(seconds: number): string {
return new Date(seconds * 1000).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
}
const inProgress = createMemo<Array<{ type: string; phase: string; id: string }>>(() => {
const result: Array<{ type: string; phase: string; id: string }> = []
for (const s of dkgSessions) {
if (s.phase === "round2" || s.phase === "confirming") {
result.push({ type: "DKG", phase: s.phase, id: s.inviteId })
}
}
for (const s of resharingSessions) {
if (s.phase === "round2" || s.phase === "confirming") {
result.push({ type: "Resharing", phase: s.phase, id: s.proposalId })
}
}
for (const s of signingSessions) {
if (s.phase === "round2") {
result.push({ type: "Signing", phase: s.phase, id: s.requestId })
}
}
return result
})
const isEmpty = createMemo(() =>
pendingDkg().length === 0 && pendingSigning().length === 0 && inProgress().length === 0
)
function ChatMessage(props: { event: TrustedEvent; mine: boolean }) {
const name = useProfileDisplay(() => props.event.pubkey)
return (
<div class="flex flex-col h-full p-4 gap-4">
<Show when={isEmpty()}>
<div class="flex flex-1 items-center justify-center">
<span class="text-sm text-gray-400 dark:text-neutral-500">No active sessions.</span>
</div>
</Show>
<Show when={pendingDkg().length > 0 || pendingSigning().length > 0}>
<div class="flex flex-col gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-neutral-400">
Needs Action
<div class={`flex gap-2 ${props.mine ? "flex-row-reverse" : "flex-row"}`}>
<Avatar pubkey={props.event.pubkey} />
<div class={`flex flex-col gap-1 max-w-[75%] ${props.mine ? "items-end" : "items-start"}`}>
<div class="flex items-baseline gap-2">
<span class="text-xs font-medium text-gray-700 dark:text-neutral-300 truncate">
{props.mine ? "You" : name()}
</span>
<For each={pendingDkg()}>
{(session: DkgSession) => (
<div class="rounded-lg border border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-950 p-3 flex flex-col gap-2">
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-amber-900 dark:text-amber-200">
DKG Invite
</span>
<span class="text-xs text-amber-700 dark:text-amber-400">
{session.members.length} members · threshold {session.threshold}
</span>
</div>
<div class="flex gap-2">
<button
class="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
onClick={() => console.log("Accept DKG", session.inviteId)}
>
Accept
</button>
<button
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
onClick={() => console.log("Decline DKG", session.inviteId)}
>
Decline
</button>
</div>
</div>
)}
</For>
<For each={pendingSigning()}>
{(session: SigningSession) => (
<div class="rounded-lg border border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-950 p-3 flex flex-col gap-2">
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-amber-900 dark:text-amber-200">
Sign Request
</span>
<span class="text-xs text-amber-700 dark:text-amber-400 font-mono truncate">
{session.msgHex.slice(0, 16)}
</span>
</div>
<div class="flex gap-2">
<button
class="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
onClick={() => console.log("Sign", session.requestId)}
>
Sign
</button>
<button
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
onClick={() => console.log("Decline signing", session.requestId)}
>
Decline
</button>
</div>
</div>
)}
</For>
</div>
</Show>
<Show when={inProgress().length > 0}>
<div class="flex flex-col gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-neutral-400">
In Progress
<span class="text-xs text-gray-400 dark:text-neutral-500">
{formatTime(props.event.created_at)}
</span>
<For each={inProgress()}>
{(item) => (
<div class="rounded-lg border border-gray-200 dark:border-neutral-600 bg-gray-50 dark:bg-neutral-800 p-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-neutral-300">
{item.type}
</span>
<span class="text-xs text-gray-500 dark:text-neutral-400 capitalize">
{item.phase}
</span>
</div>
)}
</For>
</div>
</Show>
<div
class={`px-3 py-2 rounded-lg text-sm whitespace-pre-wrap break-words ${
props.mine
? "bg-blue-600 text-white"
: "bg-gray-100 dark:bg-neutral-800 text-gray-900 dark:text-neutral-100"
}`}
>
{props.event.content}
</div>
</div>
</div>
)
}
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<string[]>(() => {
const q = quorum()
if (!q) { return [] }
return [q.inviteId, q.quorumPubkey].filter((x): x is string => Boolean(x))
})
const messages = createMemo<TrustedEvent[]>(() => 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 (
<Show
when={quorum()}
fallback={
<div class="flex flex-1 items-center justify-center p-4">
<span class="text-sm text-gray-400 dark:text-neutral-500">
Select a quorum to chat with its members.
</span>
</div>
}
>
<div class="flex flex-col h-full">
<div ref={listEl} class="flex-1 overflow-y-auto p-4 flex flex-col gap-4">
<Show
when={messages().length > 0}
fallback={
<div class="flex flex-1 items-center justify-center">
<span class="text-sm text-gray-400 dark:text-neutral-500">
No messages yet. Say hello to your quorum.
</span>
</div>
}
>
<For each={messages()}>
{(event) => <ChatMessage event={event} mine={event.pubkey === me()} />}
</For>
</Show>
</div>
<div class="border-t border-gray-200 dark:border-neutral-700 p-3 flex items-end gap-2">
<textarea
class="flex-1 resize-none rounded-lg border border-gray-200 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm text-gray-900 dark:text-neutral-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={1}
placeholder="Message the quorum…"
value={draft()}
onInput={(e) => setDraft(e.currentTarget.value)}
onKeyDown={onKeyDown}
/>
<button
class="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!draft().trim() || sending()}
onClick={send}
>
Send
</button>
</div>
</div>
</Show>
)
}
+24 -13
View File
@@ -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<string, string> = {
}
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 (
<Show
@@ -34,24 +43,21 @@ export default function QuorumDetail(props: Props) {
}
>
<div class="flex flex-col h-full">
<div class="px-4 py-3 border-b border-gray-200 dark:border-neutral-700 flex items-start justify-between">
<div class="px-4 py-3 border-b border-gray-200 dark:border-neutral-700 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium text-gray-500 dark:text-neutral-400 uppercase tracking-wide">
Quorum
<span class="text-sm font-semibold text-gray-900 dark:text-white truncate">
{headerName()}
</span>
<span class={`px-2 py-0.5 rounded-full text-[11px] font-medium ${badgeClass[activeQuorum()!.status]}`}>
<span class={`px-2 py-0.5 rounded-full text-[11px] font-medium shrink-0 ${badgeClass[activeQuorum()!.status]}`}>
{activeQuorum()!.statusLabel}
</span>
</div>
<div class="text-sm font-semibold text-gray-900 dark:text-white truncate">
{title()}
</div>
<div class="font-mono text-xs text-gray-400 dark:text-neutral-500 break-all">
{activeQuorum()!.quorumPubkey ?? activeQuorum()!.inviteId}
</div>
</div>
<div class="flex items-center gap-2 ml-4 shrink-0">
<div class="flex items-center gap-2 shrink-0 sm:ml-4">
<button
class="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
disabled={!complete()}
@@ -85,7 +91,12 @@ export default function QuorumDetail(props: Props) {
.join(" ")}
onClick={() => setTab(tab as Tab)}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
<span class="inline-flex items-center gap-1.5">
{tab.charAt(0).toUpperCase() + tab.slice(1)}
<Show when={activeQuorum() && tabHasActivity(activeQuorum()!, tab)}>
<span class="size-1.5 rounded-full bg-blue-500" title="New activity" />
</Show>
</span>
</button>
))}
</div>
@@ -100,7 +111,7 @@ export default function QuorumDetail(props: Props) {
</Match>
<Match when={(view() as any).tab === "chat"}>
<div class="md:hidden h-full">
<QuorumChat />
<QuorumChat isVisible={() => !isDesktop()} />
</div>
</Match>
</Switch>
+12 -7
View File
@@ -1,10 +1,12 @@
import { For } from "solid-js"
import { For, Show } from "solid-js"
import { displayedQuora, view, openQuorum } from "../store"
import { useProfileDisplay } from "../hooks"
import { quorumHasActivity } from "../engine/notifications"
import type { DisplayedQuorum } from "../models"
type Props = {
onNew?: () => void
onNavigate?: () => void
}
const dotClass: Record<DisplayedQuorum["status"], string> = {
@@ -14,7 +16,7 @@ const dotClass: Record<DisplayedQuorum["status"], string> = {
pending: "bg-gray-400 dark:bg-neutral-500",
}
function QuorumListItem(props: { quorum: DisplayedQuorum }) {
function QuorumListItem(props: { quorum: DisplayedQuorum; onNavigate?: () => void }) {
const name = useProfileDisplay(() => props.quorum.quorumPubkey ?? "")
const label = () => (props.quorum.quorumPubkey ? name() : "New quorum")
const isActive = () => {
@@ -29,10 +31,13 @@ function QuorumListItem(props: { quorum: DisplayedQuorum }) {
? "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300"
: "text-gray-700 dark:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-700"
}`}
onClick={() => openQuorum(props.quorum.id)}
onClick={() => { openQuorum(props.quorum.id); props.onNavigate?.() }}
>
<div class="font-medium text-sm truncate">
{label()}
<div class="font-medium text-sm truncate flex items-center gap-1.5">
<span class="truncate">{label()}</span>
<Show when={quorumHasActivity(props.quorum)}>
<span class="size-2 rounded-full bg-blue-500 shrink-0" title="New activity" />
</Show>
</div>
<div class="flex items-center gap-1.5 text-xs opacity-60">
<span class={`inline-block size-1.5 rounded-full shrink-0 ${dotClass[props.quorum.status]}`} />
@@ -53,7 +58,7 @@ export default function QuorumList(props: Props) {
</span>
<button
class="text-gray-500 dark:text-neutral-400 hover:text-gray-700 dark:hover:text-neutral-200 text-lg leading-none transition-colors"
onClick={() => props.onNew?.()}
onClick={() => { props.onNew?.(); props.onNavigate?.() }}
>
+
</button>
@@ -67,7 +72,7 @@ export default function QuorumList(props: Props) {
</p>
}
>
{(q) => <QuorumListItem quorum={q} />}
{(q) => <QuorumListItem quorum={q} onNavigate={props.onNavigate} />}
</For>
</div>
</nav>
+318
View File
@@ -0,0 +1,318 @@
import { For, Show, createMemo, createSignal } from "solid-js"
import type { JSX } from "solid-js"
import toast from "solid-toast"
import type { TrustedEvent } from "@welshman/util"
import { useReadable } from "../lib/stores"
import { useActivePubkey } from "../hooks"
import { dkgSessions, acceptDkg, declineDkg } from "../engine/dkg"
import { resharingSessions, acceptResharing, declineResharing } from "../engine/resharing"
import {
signingSessions, acceptSign, declineSign, isSigningAborted, publishedSignature,
} from "../engine/signing"
import { signRequestEvents } from "../engine/events"
import { sessionProgress, getProgress, getQuorumRecord } from "../engine/secrets"
import type { DisplayedQuorum, DkgSession, SigningSession } from "../models"
import type { ResharingSessionView } from "../engine/resharing"
// ── Styling ──────────────────────────────────────────────────────────────────
const cardClass =
"rounded-lg border border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-4 flex flex-col gap-3"
const primaryButtonClass =
"px-3 py-1.5 text-sm font-medium rounded-lg bg-green-600 text-white hover:bg-green-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
const secondaryButtonClass =
"px-3 py-1.5 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
const messageInputClass =
"w-full px-3 py-2 text-sm border border-gray-200 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
type StatusKind = "active" | "complete" | "failed"
const statusBadge: Record<StatusKind, string> = {
active: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
complete: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300",
failed: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300",
}
const phaseLabels: Record<string, string> = {
round1: "Round 1",
round2: "Round 2",
confirming: "Finalizing",
complete: "Complete",
}
const phaseLabel = (phase: string) => phaseLabels[phase] ?? phase
function signKindLabel(kind: number): string {
if (kind === 1) { return "Public note" }
if (kind === 0) { return "Profile" }
if (kind === 10002) { return "Relay selections" }
return `Event (kind ${kind})`
}
function prettyJson(content: string): string {
try {
return JSON.stringify(JSON.parse(content), null, 2)
} catch {
return content
}
}
type ActionDef = {
acceptLabel: string
onAccept: (message: string) => Promise<void>
onDecline: (message: string) => Promise<void>
}
// ── Generic session card: title + status badge, optional body, optional action ──
function SessionCard(props: {
title: string
statusKind: () => StatusKind
statusLabel: () => string
declined?: () => number
body?: JSX.Element
action?: () => ActionDef | undefined
}) {
const [message, setMessage] = createSignal("")
const [busy, setBusy] = createSignal(false)
async function run(fn: (m: string) => Promise<void>) {
if (busy()) { return }
setBusy(true)
try {
await fn(message().trim())
} catch (err) {
toast.error(err instanceof Error ? err.message : "Action failed")
setBusy(false)
}
}
return (
<div class={cardClass}>
<div class="flex items-start justify-between gap-3">
<span class="text-sm font-semibold text-gray-900 dark:text-white">{props.title}</span>
<div class="flex items-center gap-1.5 shrink-0">
<Show when={(props.declined?.() ?? 0) > 0}>
<span class="px-2 py-0.5 rounded-full text-[11px] font-medium bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300">
{props.declined!()} declined
</span>
</Show>
<span class={`px-2 py-0.5 rounded-full text-[11px] font-medium ${statusBadge[props.statusKind()]}`}>
{props.statusLabel()}
</span>
</div>
</div>
{props.body}
<Show when={props.action?.()}>
{action => (
<div class="flex flex-col gap-2">
<input
type="text"
class={messageInputClass}
placeholder="Optional message…"
value={message()}
onInput={e => setMessage(e.currentTarget.value)}
/>
<div class="flex gap-2 justify-end">
<button type="button" class={secondaryButtonClass} disabled={busy()} onClick={() => run(action().onDecline)}>
Decline
</button>
<button type="button" class={primaryButtonClass} disabled={busy()} onClick={() => run(action().onAccept)}>
{action().acceptLabel}
</button>
</div>
</div>
)}
</Show>
</div>
)
}
// ── DKG session ────────────────────────────────────────────────────────────────
function DkgSessionCard(props: { session: DkgSession }) {
const me = useActivePubkey()
const progress = useReadable(sessionProgress)
const joined = () => Object.keys(props.session.round1).length
const statusKind = (): StatusKind =>
props.session.aborted ? "failed" : props.session.complete ? "complete" : "active"
const statusLabel = () => {
if (props.session.aborted) { return "Aborted" }
if (props.session.complete) { return "Complete" }
return `${phaseLabel(props.session.phase)} · ${joined()}/${props.session.members.length} joined`
}
const action = (): ActionDef | undefined => {
progress()
const mine = me()
if (!mine || props.session.complete || props.session.aborted) { return undefined }
if (!props.session.members.includes(mine)) { return undefined }
const p = getProgress(props.session.inviteId)
if (p.sentRound1 || p.declined) { return undefined }
return {
acceptLabel: "Accept",
onAccept: m => acceptDkg(props.session.inviteId, m || undefined),
onDecline: m => declineDkg(props.session.inviteId, m || undefined),
}
}
return (
<SessionCard
title="Key generation"
statusKind={statusKind}
statusLabel={statusLabel}
declined={() => Object.keys(props.session.declines).length}
action={action}
/>
)
}
// ── Resharing session ────────────────────────────────────────────────────────
function ResharingSessionCard(props: { session: ResharingSessionView }) {
const me = useActivePubkey()
const progress = useReadable(sessionProgress)
const statusKind = (): StatusKind =>
props.session.aborted ? "failed" : props.session.phase === "complete" ? "complete" : "active"
const statusLabel = () => {
if (props.session.aborted) { return "Aborted" }
if (props.session.phase === "complete") { return "Complete" }
return `${phaseLabel(props.session.phase)} · ${props.session.round1Count}/${props.session.contributors.length} contributed`
}
const action = (): ActionDef | undefined => {
progress()
const mine = me()
if (!mine || props.session.phase === "complete" || props.session.aborted || props.session.declined) { return undefined }
if (!props.session.iAmContributor && !props.session.iAmNewMember) { return undefined }
const p = getProgress(props.session.proposalId)
if (p.sentRound1 || p.finalized || p.declined) { return undefined }
return {
acceptLabel: "Participate",
onAccept: m => acceptResharing(props.session.proposalId, m || undefined),
onDecline: m => declineResharing(props.session.proposalId, m || undefined),
}
}
return (
<SessionCard
title={`Key rotation → ${props.session.newMembers.length} members, threshold ${props.session.newThreshold}`}
statusKind={statusKind}
statusLabel={statusLabel}
declined={() => props.session.declinedCount}
action={action}
/>
)
}
// ── Signing session ────────────────────────────────────────────────────────────
function SigningSessionCard(props: { session: SigningSession; request: TrustedEvent | undefined }) {
const me = useActivePubkey()
const progress = useReadable(sessionProgress)
const threshold = () => getQuorumRecord(props.session.quorumPubkey)?.threshold ?? 0
const collected = () => Object.keys(props.session.round1).length
const aborted = () => isSigningAborted(props.session.requestId)
const kind = () => {
try {
return (JSON.parse(props.request?.content ?? "{}") as { kind?: number }).kind ?? 1
} catch {
return 1
}
}
const statusKind = (): StatusKind =>
aborted() ? "failed" : props.session.phase === "complete" ? "complete" : "active"
const statusLabel = () => {
if (aborted()) { return "Aborted" }
if (props.session.phase === "complete") { return "Signed" }
return `${phaseLabel(props.session.phase)} · ${collected()}/${threshold()} signing`
}
const action = (): ActionDef | undefined => {
progress()
const mine = me()
if (!mine || props.session.phase === "complete" || aborted()) { return undefined }
const p = getProgress(props.session.requestId)
if (p.sentRound1 || p.declined) { return undefined }
return {
acceptLabel: "Sign",
onAccept: () => acceptSign(props.session.requestId),
onDecline: m => declineSign(props.session.requestId, m || undefined),
}
}
return (
<SessionCard
title={`Signature request · ${signKindLabel(kind())}`}
statusKind={statusKind}
statusLabel={statusLabel}
declined={() => Object.keys(props.session.declines).length}
action={action}
body={
<Show when={props.request}>
<div class="flex flex-col gap-1">
<pre class="text-xs font-mono bg-gray-50 dark:bg-neutral-900 border border-gray-100 dark:border-neutral-700 rounded-lg p-2 max-h-48 overflow-auto whitespace-pre-wrap break-all text-gray-800 dark:text-neutral-200">
{prettyJson(props.request!.content)}
</pre>
<Show when={props.session.phase === "complete" && publishedSignature(props.session.requestId)}>
<span class="text-xs text-gray-400 dark:text-neutral-500 font-mono break-all">
signed event {publishedSignature(props.session.requestId)?.slice(0, 16)}
</span>
</Show>
</div>
</Show>
}
/>
)
}
// ── Sessions for one quorum, grouped (one card each), newest first ──────────────
export default function QuorumSessions(props: { quorum: DisplayedQuorum }) {
const requestById = createMemo(() => {
const m = new Map<string, TrustedEvent>()
for (const e of signRequestEvents()) { m.set(e.id, e) }
return m
})
const dkg = createMemo<DkgSession | undefined>(() =>
dkgSessions().find(s => s.inviteId === props.quorum.inviteId))
const signing = createMemo<SigningSession[]>(() => {
const pk = props.quorum.quorumPubkey
if (!pk) { return [] }
return signingSessions()
.filter(s => s.quorumPubkey === pk)
.sort((a, b) =>
(requestById().get(b.requestId)?.created_at ?? 0) - (requestById().get(a.requestId)?.created_at ?? 0))
})
const resharing = createMemo<ResharingSessionView[]>(() => {
const pk = props.quorum.quorumPubkey
if (!pk) { return [] }
return resharingSessions().filter(s => s.quorumPubkey === pk)
})
const isEmpty = createMemo(() => !dkg() && signing().length === 0 && resharing().length === 0)
return (
<Show
when={!isEmpty()}
fallback={<p class="text-sm text-gray-400 dark:text-neutral-500">No sessions yet.</p>}
>
<div class="flex flex-col gap-3">
<For each={signing()}>
{session => <SigningSessionCard session={session} request={requestById().get(session.requestId)} />}
</For>
<For each={resharing()}>
{session => <ResharingSessionCard session={session} />}
</For>
<Show when={dkg()}>
{session => <DkgSessionCard session={session()} />}
</Show>
</div>
</Show>
)
}
+32 -17
View File
@@ -2,7 +2,7 @@ import { createSignal, Show } from "solid-js"
import toast from "solid-toast"
import type { DkgSession } from "../../models"
import PubkeyInput from "../PubkeyInput"
import { createQuorum } from "../../quorum"
import { createQuorum, acceptDkg, declineDkg } from "../../engine/dkg"
import { useActivePubkey, useAutoThreshold } from "../../hooks"
const inputClass =
@@ -93,19 +93,32 @@ export function ProposeQuorum(props: { onClose: () => void }) {
}
export function DkgInviteResponse(props: { session: DkgSession; onClose: () => void }) {
const [reason, setReason] = createSignal("")
const [message, setMessage] = createSignal("")
const [submitting, setSubmitting] = createSignal(false)
function handleAccept() {
console.log("DkgInviteResponse accept", { inviteId: props.session.inviteId })
props.onClose()
async function handleAccept() {
setSubmitting(true)
try {
// The message is posted to the quorum chat once DKG finalizes.
await acceptDkg(props.session.inviteId, message().trim() || undefined)
props.onClose()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to accept invite")
} finally {
setSubmitting(false)
}
}
function handleDecline() {
console.log("DkgInviteResponse decline", {
inviteId: props.session.inviteId,
reason: reason(),
})
props.onClose()
async function handleDecline() {
setSubmitting(true)
try {
await declineDkg(props.session.inviteId, message().trim() || undefined)
props.onClose()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to decline invite")
} finally {
setSubmitting(false)
}
}
return (
@@ -123,29 +136,31 @@ export function DkgInviteResponse(props: { session: DkgSession; onClose: () => v
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-neutral-300">
Reason (optional, shown if declining)
Message (optional posted to chat on accept, or shared as a reason on decline)
</label>
<textarea
class={inputClass}
rows={3}
placeholder="Optional reason..."
value={reason()}
onInput={e => setReason(e.currentTarget.value)}
placeholder="Optional message..."
value={message()}
onInput={e => setMessage(e.currentTarget.value)}
/>
</div>
<div class="flex gap-2 justify-end">
<button
type="button"
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors disabled:opacity-50"
onClick={handleDecline}
disabled={submitting()}
>
Decline
</button>
<button
type="button"
class="px-4 py-2 text-sm font-medium rounded-lg bg-green-600 text-white hover:bg-green-700 transition-colors"
class="px-4 py-2 text-sm font-medium rounded-lg bg-green-600 text-white hover:bg-green-700 transition-colors disabled:opacity-50"
onClick={handleAccept}
disabled={submitting()}
>
Accept
</button>
+54 -20
View File
@@ -1,8 +1,11 @@
import { createSignal, Show } from "solid-js"
import toast from "solid-toast"
import type { ResharingSession } from "../../models"
import PubkeyInput from "../PubkeyInput"
import { validateMessagingRelays } from "../../lib/relays"
import { useAutoThreshold, useProfileDisplay } from "../../hooks"
import { proposeResharing, acceptResharing, declineResharing } from "../../engine/resharing"
import { getQuorumRecord } from "../../engine/secrets"
export function ProposeResharing(props: { quorumPubkey: string; onClose: () => void }) {
const [newMembers, setNewMembers] = createSignal<string[]>([])
@@ -26,22 +29,36 @@ export function ProposeResharing(props: { quorumPubkey: string; onClose: () => v
return
}
setSubmitting(true)
const relayError = await validateMessagingRelays(members)
setSubmitting(false)
if (relayError) {
setError(relayError)
// Contributors are the current quorum members who reshare their shards. Default to
// all current members so the contributing set always meets the existing threshold.
const record = getQuorumRecord(props.quorumPubkey)
if (!record) {
setError("Quorum is not yet complete; cannot rotate keys.")
return
}
const contributors = record.members.map(m => m.pubkey)
console.log("ProposeResharing", {
quorumPubkey: props.quorumPubkey,
newMembers: members,
newThreshold: t,
})
setSubmitting(true)
try {
// Optimistic: a missing messaging relay is surfaced as a warning, but never blocks
// the rotation — the proposal is stored locally and delivered best-effort.
const relayError = await validateMessagingRelays(members)
if (relayError) {
toast(relayError)
}
props.onClose()
await proposeResharing({
quorumPubkey: props.quorumPubkey,
newMembers: members,
newThreshold: t,
contributors,
})
props.onClose()
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to propose rotation")
} finally {
setSubmitting(false)
}
}
return (
@@ -101,15 +118,30 @@ export function ProposeResharing(props: { quorumPubkey: string; onClose: () => v
export function ResharingInviteResponse(props: { session: ResharingSession; onClose: () => void }) {
const quorumName = useProfileDisplay(() => props.session.quorumPubkey)
const [submitting, setSubmitting] = createSignal(false)
function handleParticipate() {
console.log("ResharingInviteResponse: participate", props.session.proposalId)
props.onClose()
async function handleParticipate() {
setSubmitting(true)
try {
await acceptResharing(props.session.proposalId)
props.onClose()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to participate")
} finally {
setSubmitting(false)
}
}
function handleDecline() {
console.log("ResharingInviteResponse: decline", props.session.proposalId)
props.onClose()
async function handleDecline() {
setSubmitting(true)
try {
await declineResharing(props.session.proposalId)
props.onClose()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to decline")
} finally {
setSubmitting(false)
}
}
return (
@@ -133,14 +165,16 @@ export function ResharingInviteResponse(props: { session: ResharingSession; onCl
<button
type="button"
onClick={handleDecline}
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
disabled={submitting()}
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors disabled:opacity-50"
>
Decline
</button>
<button
type="button"
onClick={handleParticipate}
class="px-4 py-2 text-sm font-medium rounded-lg bg-green-600 text-white hover:bg-green-700 transition-colors"
disabled={submitting()}
class="px-4 py-2 text-sm font-medium rounded-lg bg-green-600 text-white hover:bg-green-700 transition-colors disabled:opacity-50"
>
Participate
</button>
+404 -57
View File
@@ -1,97 +1,444 @@
import { createSignal } from "solid-js"
import { createSignal, createMemo, For, Index, Show, onMount } from "solid-js"
import toast from "solid-toast"
import { RelayMode } from "@welshman/util"
import { derivePubkeyRelays, loadProfile } from "@welshman/app"
import type { SigningSession } from "../../models"
import { displayedQuora } from "../../store"
import { validateMessagingRelays } from "../../lib/relays"
import { requestSignature, acceptSign, declineSign } from "../../engine/signing"
import { signRequestEvents } from "../../engine/events"
import { useReadable } from "../../lib/stores"
const inputClass =
"w-full px-3 py-2 text-sm border border-gray-200 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
const primaryButtonClass =
"px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
const secondaryButtonClass =
"px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
const declineButtonClass =
"px-4 py-2 text-sm font-medium rounded-lg bg-green-600 text-white hover:bg-green-700 transition-colors"
type EventKind = 1 | 10002 | 0
type Mode = EventKind | "custom"
type EventTemplate = { kind: number; tags: string[][]; content: string }
const modeOptions: { mode: Mode; label: string; description: string }[] = [
{ mode: 1, label: "Public Note", description: "A kind 1 text note published as the quorum." },
{ mode: 10002, label: "Relay Selections", description: "A kind 10002 outbox/inbox relay list." },
{ mode: 0, label: "Profile", description: "A kind 0 metadata event describing the quorum." },
{ mode: "custom", label: "Custom JSON", description: "Paste a raw event template (kind, tags, content) to sign." },
]
/** A single relay row for the kind 10002 editor. */
type RelayRow = { url: string; read: boolean; write: boolean }
/** Validate a pasted event template, returning a normalized {kind, tags, content} or an error. */
function parseCustomTemplate(json: string): { template?: EventTemplate; error?: string } {
let parsed: unknown
try {
parsed = JSON.parse(json)
} catch {
return { error: "Invalid JSON." }
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return { error: "Expected a JSON object." }
}
const obj = parsed as Record<string, unknown>
if (typeof obj.kind !== "number" || !Number.isInteger(obj.kind) || obj.kind < 0) {
return { error: 'Missing or invalid "kind" (a non-negative integer).' }
}
const content = obj.content ?? ""
if (typeof content !== "string") {
return { error: '"content" must be a string.' }
}
const tags = obj.tags ?? []
if (!Array.isArray(tags) || !tags.every(t => Array.isArray(t) && t.every(x => typeof x === "string"))) {
return { error: '"tags" must be an array of string arrays.' }
}
return { template: { kind: obj.kind, tags: tags as string[][], content } }
}
export function ProposeSign(props: { quorumPubkey: string; onClose: () => void }) {
const [message, setMessage] = createSignal("")
const [mode, setMode] = createSignal<Mode>(1)
const [note, setNote] = createSignal("")
const [relays, setRelays] = createSignal<RelayRow[]>([{ url: "", read: true, write: true }])
const [name, setName] = createSignal("")
const [about, setAbout] = createSignal("")
const [picture, setPicture] = createSignal("")
const [nip05, setNip05] = createSignal("")
const [customJson, setCustomJson] = createSignal("")
const [error, setError] = createSignal("")
const [submitting, setSubmitting] = createSignal(false)
const memberPubkeys = () =>
displayedQuora().find(q => q.quorumPubkey === props.quorumPubkey)?.members.map(m => m.pubkey) ?? []
const quorum = createMemo(() =>
displayedQuora().find(q => q.quorumPubkey === props.quorumPubkey))
async function handleSubmit() {
if (!message().trim()) {
setError("Message is required.")
// Background-load the quorum's profile and relay lists; warn when it has no outbox relays,
// since a signed event would then have nowhere to be published.
onMount(() => { if (props.quorumPubkey) { loadProfile(props.quorumPubkey) } })
const outboxRelays = useReadable(derivePubkeyRelays(props.quorumPubkey, RelayMode.Write))
const noRelays = () => Boolean(props.quorumPubkey) && outboxRelays().length === 0
function updateRelay(i: number, patch: Partial<RelayRow>) {
setRelays(rs => rs.map((r, idx) => (idx === i ? { ...r, ...patch } : r)))
}
function addRelay() {
setRelays(rs => [...rs, { url: "", read: true, write: true }])
}
function removeRelay(i: number) {
setRelays(rs => rs.filter((_, idx) => idx !== i))
}
function composeTemplate(): { template?: EventTemplate; error?: string } {
const m = mode()
if (m === "custom") {
if (!customJson().trim()) { return { error: "Paste an event JSON." } }
return parseCustomTemplate(customJson())
}
if (m === 1) {
if (!note().trim()) { return { error: "Note content is required." } }
return { template: { kind: 1, tags: [], content: note() } }
}
if (m === 10002) {
const rows = relays().filter(r => r.url.trim())
if (!rows.length) { return { error: "Add at least one relay." } }
const tags = rows.map(r => {
const tag = ["r", r.url.trim()]
// Per NIP-65: omit the marker when both read+write, else mark the direction.
if (r.read && !r.write) { tag.push("read") }
if (r.write && !r.read) { tag.push("write") }
return tag
})
return { template: { kind: 10002, tags, content: "" } }
}
const meta: Record<string, string> = {}
if (name().trim()) { meta.name = name().trim() }
if (about().trim()) { meta.about = about().trim() }
if (picture().trim()) { meta.picture = picture().trim() }
if (nip05().trim()) { meta.nip05 = nip05().trim() }
if (!Object.keys(meta).length) { return { error: "Fill in at least one profile field." } }
return { template: { kind: 0, tags: [], content: JSON.stringify(meta) } }
}
async function handleSubmit(e: Event) {
e.preventDefault()
if (!props.quorumPubkey) {
setError("This quorum is not complete yet.")
return
}
const { template, error: buildError } = composeTemplate()
if (!template) {
setError(buildError ?? "Invalid event.")
return
}
setError("")
setSubmitting(true)
const relayError = await validateMessagingRelays(memberPubkeys())
setSubmitting(false)
if (relayError) {
setError(relayError)
return
try {
// Optimistic: the request is stored and shown immediately even if no member
// is reachable. requestSignature does not hard-fail on undeliverability.
await requestSignature(props.quorumPubkey, template)
props.onClose()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to request signature")
} finally {
setSubmitting(false)
}
}
console.log({ quorumPubkey: props.quorumPubkey, message: message() })
props.onClose()
return (
<form onSubmit={handleSubmit} class="flex flex-col gap-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Sign Event</h2>
<Show when={noRelays()}>
<div class="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/50 px-3 py-2">
<p class="text-xs text-amber-800 dark:text-amber-300">
This quorum has no relays set, so its signed events can't be published anywhere. Sign a
“Relay Selections” (kind 10002) event for the quorum first to give it an outbox.
</p>
</div>
</Show>
<div class="flex flex-col gap-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-neutral-300">Event Kind</label>
<div class="grid grid-cols-2 gap-2">
<For each={modeOptions}>
{opt => (
<button
type="button"
onClick={() => setMode(opt.mode)}
class={`px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${
mode() === opt.mode
? "border-blue-600 bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300"
: "border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700"
}`}
>
{opt.label}
</button>
)}
</For>
</div>
<p class="text-xs text-gray-400 dark:text-neutral-500">
{modeOptions.find(o => o.mode === mode())?.description}
</p>
</div>
<Show when={mode() === 1}>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-neutral-300">Note</label>
<textarea
rows={4}
class={inputClass}
placeholder="What's on the quorum's mind?"
value={note()}
onInput={e => setNote(e.currentTarget.value)}
/>
</div>
</Show>
<Show when={mode() === 10002}>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-gray-700 dark:text-neutral-300">Relays</label>
<Index each={relays()}>
{(relay, i) => (
<div class="flex items-center gap-2">
<input
type="text"
class={inputClass}
placeholder="wss://relay.example.com"
value={relay().url}
onInput={e => updateRelay(i, { url: e.currentTarget.value })}
/>
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-neutral-400">
<input
type="checkbox"
checked={relay().read}
onChange={e => updateRelay(i, { read: e.currentTarget.checked })}
/>
Read
</label>
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-neutral-400">
<input
type="checkbox"
checked={relay().write}
onChange={e => updateRelay(i, { write: e.currentTarget.checked })}
/>
Write
</label>
<button
type="button"
class="text-sm text-gray-400 hover:text-red-500 px-1"
onClick={() => removeRelay(i)}
aria-label="Remove relay"
>
</button>
</div>
)}
</Index>
<button
type="button"
class="self-start text-sm text-blue-600 hover:text-blue-700"
onClick={addRelay}
>
+ Add relay
</button>
</div>
</Show>
<Show when={mode() === 0}>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-neutral-300">Name</label>
<input
type="text"
class={inputClass}
value={name()}
onInput={e => setName(e.currentTarget.value)}
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-neutral-300">About</label>
<textarea
rows={2}
class={inputClass}
value={about()}
onInput={e => setAbout(e.currentTarget.value)}
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-neutral-300">
Picture URL
</label>
<input
type="text"
class={inputClass}
value={picture()}
onInput={e => setPicture(e.currentTarget.value)}
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-neutral-300">NIP-05</label>
<input
type="text"
class={inputClass}
value={nip05()}
onInput={e => setNip05(e.currentTarget.value)}
/>
</div>
</div>
</Show>
<Show when={mode() === "custom"}>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-neutral-300">Event JSON</label>
<textarea
rows={8}
class={`${inputClass} font-mono`}
placeholder={'{\n "kind": 1,\n "tags": [],\n "content": "gm"\n}'}
value={customJson()}
onInput={e => setCustomJson(e.currentTarget.value)}
/>
<p class="text-xs text-gray-400 dark:text-neutral-500">
Provide kind, tags, and content. The quorum's pubkey and created_at are filled in automatically.
</p>
</div>
</Show>
<p class="text-xs text-gray-400 dark:text-neutral-500">
{quorum()
? `Requires ${quorum()!.threshold} of ${quorum()!.members.length} members to sign.`
: "Loading quorum…"}
</p>
<Show when={error()}>
<p class="text-sm text-red-600 dark:text-red-400">{error()}</p>
</Show>
<div class="flex gap-2 justify-end">
<button type="button" class={secondaryButtonClass} onClick={props.onClose}>
Cancel
</button>
<button type="submit" class={primaryButtonClass} disabled={submitting()}>
{submitting() ? "Requesting…" : "Request Signature"}
</button>
</div>
</form>
)
}
function signKindLabel(kind: number): string {
if (kind === 1) { return "Public Note" }
if (kind === 10002) { return "Relay Selections" }
if (kind === 0) { return "Profile" }
return `Kind ${kind}`
}
export function SignRequestResponse(props: { session: SigningSession; onClose: () => void }) {
const [reason, setReason] = createSignal("")
const [submitting, setSubmitting] = createSignal(false)
const quorum = createMemo(() =>
displayedQuora().find(q => q.quorumPubkey === props.session.quorumPubkey))
const threshold = () => quorum()?.threshold ?? props.session.signingSet.length
const collected = () => Object.keys(props.session.round1).length
// Derive the unsigned event's kind from the kind-7058 request content.
const requestKind = createMemo(() => {
const req = signRequestEvents().find(e => e.id === props.session.requestId)
if (!req) { return undefined }
try {
return (JSON.parse(req.content) as { kind?: number }).kind
} catch {
return undefined
}
})
async function handleSign() {
setSubmitting(true)
try {
await acceptSign(props.session.requestId)
props.onClose()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to sign")
} finally {
setSubmitting(false)
}
}
async function handleDecline() {
setSubmitting(true)
try {
await declineSign(props.session.requestId, reason().trim() || undefined)
props.onClose()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to decline")
} finally {
setSubmitting(false)
}
}
return (
<div class="flex flex-col gap-4">
<h2 class="text-base font-semibold text-gray-900 dark:text-white">Request Signature</h2>
<textarea
rows={4}
class="w-full px-3 py-2 text-sm border border-gray-200 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
value={message()}
onInput={e => setMessage(e.currentTarget.value)}
/>
{error() && (
<p class="text-sm text-red-600 dark:text-red-400">{error()}</p>
)}
<div class="flex gap-2 justify-end">
<button
type="button"
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
onClick={props.onClose}
>
Cancel
</button>
<button
type="button"
disabled={submitting()}
class="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
onClick={handleSubmit}
>
{submitting() ? "Checking…" : "Request Signature"}
</button>
</div>
</div>
)
}
export function SignRequestResponse(props: { session: SigningSession; onClose: () => void }) {
return (
<div class="flex flex-col gap-4">
<h2 class="text-base font-semibold text-gray-900 dark:text-white">Signature Request</h2>
<Show when={requestKind() !== undefined}>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-neutral-400">Event</span>
<span class="text-sm text-gray-900 dark:text-white">
{signKindLabel(requestKind()!)}
</span>
</div>
</Show>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-neutral-400">Message hash</span>
<span class="font-mono text-sm text-gray-900 dark:text-white">
{props.session.msgHex.slice(0, 16)}
{props.session.msgHex.slice(0, 16)}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-neutral-400">Signing set</span>
<span class="text-xs text-gray-500 dark:text-neutral-400">Collected</span>
<span class="text-sm text-gray-900 dark:text-white">
{props.session.signingSet.length} members
{collected()} / {threshold()} signers
</span>
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-neutral-300">
Reason (optional, shown if declining)
</label>
<textarea
class={inputClass}
rows={3}
placeholder="Optional reason…"
value={reason()}
onInput={e => setReason(e.currentTarget.value)}
/>
</div>
<div class="flex gap-2 justify-end">
<button
type="button"
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
onClick={props.onClose}
class={secondaryButtonClass}
disabled={submitting()}
onClick={handleDecline}
>
Decline
</button>
<button
type="button"
class="px-4 py-2 text-sm font-medium rounded-lg bg-green-600 text-white hover:bg-green-700 transition-colors"
onClick={props.onClose}
class={declineButtonClass}
disabled={submitting()}
onClick={handleSign}
>
Sign
</button>
+16 -61
View File
@@ -1,73 +1,28 @@
import { createSignal, createEffect, onCleanup, For } from "solid-js"
import type { TrustedEvent, Filter } from "@welshman/util"
import { repository } from "@welshman/app"
import { deriveEvents } from "@welshman/store"
import { createEffect, Show } from "solid-js"
import { activeQuorum } from "../../store"
import { PROTOCOL_KINDS } from "../../protocol"
function kindLabel(kind: number): string {
if (kind === 7050) { return "DKG Invite" }
if (kind === 7051) { return "DKG Round 1" }
if (kind === 7052) { return "DKG Round 2" }
if (kind === 7053) { return "DKG Complete" }
if (kind === 7054) { return "Resharing Proposed" }
if (kind === 7055) { return "Resharing Round 1" }
if (kind === 7056) { return "Resharing Round 2" }
if (kind === 7057) { return "Rotation Complete" }
if (kind === 7058) { return "Sign Request" }
if (kind === 7059) { return "Signing Round 1" }
if (kind === 7060) { return "Signing Round 2" }
if (kind === 7061) { return "Declined" }
return `Event ${kind}`
}
function relativeTime(createdAt: number): string {
const now = Math.floor(Date.now() / 1000)
const age = now - createdAt
if (age < 60) { return `${age}s ago` }
if (age < 3600) { return `${Math.floor(age / 60)}m ago` }
if (age < 86400) { return `${Math.floor(age / 3600)}h ago` }
return `${Math.floor(age / 86400)}d ago`
}
import { markTabViewed } from "../../engine/notifications"
import { dkgSessions } from "../../engine/dkg"
import { signingSessions } from "../../engine/signing"
import { resharingSessions } from "../../engine/resharing"
import QuorumSessions from "../QuorumSessions"
export default function QuorumLog() {
const [events, setEvents] = createSignal<TrustedEvent[]>([])
// Mark the log read while it is on screen. Touch the session signals so this re-runs (and
// re-marks read) as activity arrives, keeping the tab read until the user navigates away.
createEffect(() => {
const quorum = activeQuorum()
if (!quorum) { setEvents([]); return }
// Completed quora tag events with their pubkey; a pending invite is referenced by
// its id (the invite event itself, plus round-1/decline events that "e"-tag it).
const filters: Filter[] = quorum.quorumPubkey
? [{ kinds: PROTOCOL_KINDS, "#quorum": [quorum.quorumPubkey] }]
: [{ kinds: PROTOCOL_KINDS, "#e": [quorum.inviteId] }, { ids: [quorum.inviteId] }]
const store = deriveEvents({ repository, filters })
onCleanup(store.subscribe(evs =>
setEvents(() => [...evs].sort((a, b) => b.created_at - a.created_at))))
if (!quorum) { return }
dkgSessions()
signingSessions()
resharingSessions()
markTabViewed(quorum.inviteId, "log")
})
return (
<div class="p-4">
<For
each={events()}
fallback={<p class="text-sm text-gray-400 dark:text-neutral-500">No events yet.</p>}
>
{(event) => (
<div class="flex items-center justify-between py-2 border-b border-gray-100 dark:border-neutral-700 last:border-b-0">
<div class="flex flex-col">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{kindLabel(event.kind)}
</span>
<span class="text-xs text-gray-400 dark:text-neutral-500 font-mono">
{event.id.slice(0, 8)}
</span>
</div>
<span class="text-xs text-gray-400 dark:text-neutral-500 ml-4 shrink-0">
{relativeTime(event.created_at)}
</span>
</div>
)}
</For>
<Show when={activeQuorum()}>
{q => <QuorumSessions quorum={q()} />}
</Show>
</div>
)
}
+8 -1
View File
@@ -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<number | null>(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)
+88
View File
@@ -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<void> {
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<void> {
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<TrustedEvent[]>([])
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)
}
+35
View File
@@ -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<void> {
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<void> {
if (opts.storeLocal !== false) { repository.publish(rumor) }
await Promise.all(recipients.map(r => deliverTo(r, rumor).catch(e => console.error("deliver failed", r, e))))
}
+517
View File
@@ -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<Hex, Round1Data> = {}
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<number, Hex> = {}
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<Hex, string> = {}
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<string>()
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<void> {
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<number, Hex[]> = {}
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<number, Hex> = { ...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<void> {
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<string> {
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<void> {
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<void> {
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)
}
}
+102
View File
@@ -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<TrustedEvent[]>([])
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<Hex, Hex[]> {
const out: Record<Hex, Hex[]> = {}
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<string, TrustedEvent[]> {
const out = new Map<string, TrustedEvent[]>()
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<string, TrustedEvent> {
const out = new Map<string, TrustedEvent>()
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
}
+19
View File
@@ -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()
}
+116
View File
@@ -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<string, number> }
const notifications = synced<NotificationState>({
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<NotificationState>(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<TrustedEvent[]>([])
deriveEvents({ repository, filters: [{ kinds: PROTOCOL_KINDS }] }).subscribe(setAllProtocol)
const [allChat, setAllChat] = createSignal<TrustedEvent[]>([])
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")
}
+642
View File
@@ -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 = <T,>(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<Hex, Hex[]>
/** 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<Record<string, ReturnType<typeof getProgress>>>({})
const [quoraMirror, setQuoraMirror] = createSignal<unknown>(undefined)
const [shardsMirror, setShardsMirror] = createSignal<unknown>(undefined)
sessionProgress.subscribe(v => setProgressMirror(v))
quora.subscribe(v => setQuoraMirror(v))
shardRecords.subscribe(v => setShardsMirror(v))
// ── Derived session list ───────────────────────────────────────────────────────
const [sessions, setSessions] = createSignal<ResharingSessionView[]>([])
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<Hex, Round1Data> = {}
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<number, Hex> = {}
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<Hex, string> = {}
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<typeof getProgress>,
contributors: Hex[],
round1: Record<Hex, Round1Data>,
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<string> {
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<void> {
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<void> {
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<string>()
// 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<string>()
function isAccepted(s: ResharingSessionView, progress: ReturnType<typeof getProgress>): 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<void> {
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<originalIndex, Hex[]> — used to verify each contributor's shard.
const originalCommitments: Record<number, Hex[]> = {}
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<number, Hex[]> = {}
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<string>()
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<Hex, { quorum?: Hex; transcript?: string }>()
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
}
+132
View File
@@ -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<T>(store: Readable<T>): 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<Record<string, SessionProgress>>({
key: "nq:session-progress",
storage: localStorageProvider,
defaultValue: {},
})
export function getProgress(id: string): SessionProgress {
return read(sessionProgress)[id] ?? {}
}
export function patchProgress(id: string, patch: Partial<SessionProgress>): 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<QuorumRecord[]>({
key: "nq:quora",
storage: localStorageProvider,
defaultValue: [],
})
export const shardRecords = synced<Record<string, ShardRecord>>({
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<void> {
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<bigint | undefined> {
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<string, SigningNonces>()
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)
}
+603
View File
@@ -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<string, TrustedEvent>,
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<Hex, { D: Hex; E: Hex }> = {}
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<number, Hex> = {}
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<Hex, string> = {}
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<Hex, unknown>): 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<string> {
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<void> {
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<void> {
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<string, string>()
/** 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<number, { D: Hex; E: Hex }>
// index -> z for every share whose signer-set matches canonical S
sharesByIndex: Record<number, Hex>
// every 7060 for the session, for the signer-set agreement check
shareEvents: TrustedEvent[]
}
function buildSnapshot(
mine: Hex,
req: TrustedEvent,
noncesBySession: Map<string, TrustedEvent[]>,
sharesBySession: Map<string, TrustedEvent[]>,
): 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<number, { D: Hex; E: Hex }> = {}
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<number, Hex> = {}
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>>): 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<number, { D: Hex; E: Hex }> = {}
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<number, { D: Hex; E: Hex }> = {}
const shares: Record<number, bigint> = {}
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<void>> = []
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))
}
})
}
})
}
+11
View File
@@ -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 }
+6
View File
@@ -79,6 +79,12 @@ export type DkgSession = {
round2: Record<number, Hex>
/** Members who declined, keyed by pubkey, value is their optional reason */
declines: Record<Hex, string>
/** 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 ─────────────────────────────────────────────────────────
+9 -3
View File
@@ -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() }
-61
View File
@@ -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<string> {
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
}
+50 -3
View File
@@ -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<View>("inbox")
export const [view, setView] = createSignal<View>("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<QuorumRecord[]>([])
completedQuora.subscribe(setFinishedQuora)
function authorsByInviteId(events: TrustedEvent[]): Map<string, Set<string>> {
const m = new Map<string, Set<string>>()
for (const e of events) {
@@ -149,6 +156,29 @@ export const displayedQuora = createMemo<DisplayedQuorum[]>(() => {
} 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<DisplayedQuorum | undefined>(() => {
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)