Add signing engine, refine ui
This commit is contained in:
+40
-32
@@ -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"}>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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() }
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user