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 Avatar from "./components/Avatar"
|
||||||
import { displayProfile } from "@welshman/util"
|
import { displayProfile } from "@welshman/util"
|
||||||
import { useActivePubkey, useProfile } from "./hooks"
|
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 pubkey = useActivePubkey()
|
||||||
const shortKey = () => `${pubkey().slice(0, 8)}…${pubkey().slice(-4)}`
|
const shortKey = () => `${pubkey().slice(0, 8)}…${pubkey().slice(-4)}`
|
||||||
const profile = useProfile(pubkey)
|
const profile = useProfile(pubkey)
|
||||||
@@ -19,28 +20,15 @@ function SidebarContent(props: { onNew: () => void }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full">
|
<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">
|
<div class="px-3 py-2 flex-1 overflow-y-auto">
|
||||||
<QuorumList onNew={props.onNew} />
|
<QuorumList onNew={props.onNew} onNavigate={props.onNavigate} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 dark:border-neutral-700 p-3">
|
<div class="border-t border-gray-200 dark:border-neutral-700 p-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<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"
|
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()} />
|
<Avatar pubkey={pubkey()} />
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{name()}</p>
|
<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
|
<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"
|
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"
|
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">
|
<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" />
|
<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">
|
<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>
|
<span class="text-base font-bold text-gray-900 dark:text-white">NQ</span>
|
||||||
</div>
|
</div>
|
||||||
<SidebarContent onNew={() => setShowProposeQuorum(true)} />
|
<SidebarContent onNew={() => setShowProposeQuorum(true)} onNavigate={() => setDrawerOpen(false)} />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Mobile drawer backdrop */}
|
{/* Mobile drawer backdrop */}
|
||||||
@@ -98,19 +86,29 @@ export default function Layout() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<SidebarContent onNew={() => setShowProposeQuorum(true)} />
|
<SidebarContent onNew={() => setShowProposeQuorum(true)} onNavigate={() => setDrawerOpen(false)} />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Desktop: 3-column layout */}
|
{/* Desktop: 3-column layout */}
|
||||||
<div class="hidden md:flex flex-1 min-w-0 overflow-hidden">
|
<div class="hidden md:flex flex-1 min-w-0 overflow-hidden">
|
||||||
{/* Col 2: main content */}
|
{/* Col 2: main content */}
|
||||||
<div class="flex-1 min-w-0 overflow-y-auto">
|
<div class="flex-1 min-w-0 overflow-y-auto">
|
||||||
<Show when={view() === "inbox"}>
|
<Show when={view() === "home"}>
|
||||||
<div class="p-6">
|
<div class="flex items-center justify-center min-h-full p-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Inbox</h2>
|
<div class="flex flex-col items-center text-center gap-4 max-w-sm">
|
||||||
<p class="text-sm text-gray-500 dark:text-neutral-400">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Welcome to NQ</h1>
|
||||||
Pending invitations and requests will appear here.
|
<p class="text-sm text-gray-500 dark:text-neutral-400">
|
||||||
</p>
|
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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={view() === "account"}>
|
<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>
|
<span class="text-sm font-semibold text-gray-700 dark:text-neutral-300">Sessions</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="flex-1 overflow-y-auto">
|
||||||
<QuorumChat />
|
<QuorumChat isVisible={isDesktop} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -152,12 +150,22 @@ export default function Layout() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex-1 overflow-y-auto">
|
<main class="flex-1 overflow-y-auto">
|
||||||
<Show when={view() === "inbox"}>
|
<Show when={view() === "home"}>
|
||||||
<div class="p-6">
|
<div class="flex items-center justify-center min-h-full p-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Inbox</h2>
|
<div class="flex flex-col items-center text-center gap-4 max-w-sm">
|
||||||
<p class="text-sm text-gray-500 dark:text-neutral-400">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Welcome to NQ</h1>
|
||||||
Pending invitations and requests will appear here.
|
<p class="text-sm text-gray-500 dark:text-neutral-400">
|
||||||
</p>
|
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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={view() === "account"}>
|
<Show when={view() === "account"}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createSignal, Show } from "solid-js"
|
|||||||
import toast from "solid-toast"
|
import toast from "solid-toast"
|
||||||
import { getNip07, Nip07Signer } from "@welshman/signer"
|
import { getNip07, Nip07Signer } from "@welshman/signer"
|
||||||
import { loginWithNip01, loginWithNip07 } from "@welshman/app"
|
import { loginWithNip01, loginWithNip07 } from "@welshman/app"
|
||||||
|
import { onLogin } from "./engine/notifications"
|
||||||
|
|
||||||
type Tab = "extension" | "nsec" | "nip46"
|
type Tab = "extension" | "nsec" | "nip46"
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ export default function Login() {
|
|||||||
if (!getNip07()) { throw new Error("Extension not found") }
|
if (!getNip07()) { throw new Error("Extension not found") }
|
||||||
const pubkey = await new Nip07Signer().getPubkey()
|
const pubkey = await new Nip07Signer().getPubkey()
|
||||||
loginWithNip07(pubkey)
|
loginWithNip07(pubkey)
|
||||||
|
onLogin()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e?.message ?? "Extension not found")
|
toast.error(e?.message ?? "Extension not found")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -82,6 +84,7 @@ export default function Login() {
|
|||||||
throw new Error("Invalid key")
|
throw new Error("Invalid key")
|
||||||
}
|
}
|
||||||
loginWithNip01(secret)
|
loginWithNip01(secret)
|
||||||
|
onLogin()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e?.message ?? "Invalid key")
|
toast.error(e?.message ?? "Invalid key")
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+18
-1
@@ -1,7 +1,9 @@
|
|||||||
import "@welshman/app" // side effects: wires pool → repository/tracker/router
|
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 { routerContext } from "@welshman/router"
|
||||||
|
import { sync, localStorageProvider } from "@welshman/store"
|
||||||
import { hydrateRepository, persistRepository } from "./storage"
|
import { hydrateRepository, persistRepository } from "./storage"
|
||||||
|
import { startEngine } from "./engine"
|
||||||
|
|
||||||
const parseRelays = (v?: string): string[] =>
|
const parseRelays = (v?: string): string[] =>
|
||||||
(v ?? "").split(",").map(s => s.trim()).filter(Boolean)
|
(v ?? "").split(",").map(s => s.trim()).filter(Boolean)
|
||||||
@@ -13,9 +15,24 @@ routerContext.getIndexerRelays = () => INDEXER_RELAYS
|
|||||||
routerContext.getSearchRelays = () => SEARCH_RELAYS
|
routerContext.getSearchRelays = () => SEARCH_RELAYS
|
||||||
routerContext.getDefaultRelays = () => INDEXER_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.
|
// Opt into NIP-59 gift-wrap unwrapping so inbox rumors are decrypted and stored automatically.
|
||||||
shouldUnwrap.set(true)
|
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.
|
// Repository persistence: register the flush listener, then hydrate from disk.
|
||||||
persistRepository()
|
persistRepository()
|
||||||
void hydrateRepository()
|
void hydrateRepository()
|
||||||
|
|||||||
+145
-122
@@ -1,132 +1,155 @@
|
|||||||
import { For, Show, createMemo } from "solid-js"
|
import { For, Show, createMemo, createSignal, createEffect } from "solid-js"
|
||||||
import { activeQuorum, dkgSessions, resharingSessions, signingSessions } from "../store"
|
import type { TrustedEvent } from "@welshman/util"
|
||||||
import type { DkgSession, ResharingSession, SigningSession } from "../models"
|
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() {
|
function formatTime(seconds: number): string {
|
||||||
const pendingDkg = createMemo<DkgSession[]>(() =>
|
return new Date(seconds * 1000).toLocaleString(undefined, {
|
||||||
dkgSessions.filter(s => s.phase === "round1")
|
month: "short",
|
||||||
)
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
const pendingSigning = createMemo<SigningSession[]>(() => {
|
minute: "2-digit",
|
||||||
const q = activeQuorum()
|
|
||||||
return signingSessions.filter(s => s.phase === "round1" && s.quorumPubkey === q?.quorumPubkey)
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const inProgress = createMemo<Array<{ type: string; phase: string; id: string }>>(() => {
|
function ChatMessage(props: { event: TrustedEvent; mine: boolean }) {
|
||||||
const result: Array<{ type: string; phase: string; id: string }> = []
|
const name = useProfileDisplay(() => props.event.pubkey)
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full p-4 gap-4">
|
<div class={`flex gap-2 ${props.mine ? "flex-row-reverse" : "flex-row"}`}>
|
||||||
<Show when={isEmpty()}>
|
<Avatar pubkey={props.event.pubkey} />
|
||||||
<div class="flex flex-1 items-center justify-center">
|
<div class={`flex flex-col gap-1 max-w-[75%] ${props.mine ? "items-end" : "items-start"}`}>
|
||||||
<span class="text-sm text-gray-400 dark:text-neutral-500">No active sessions.</span>
|
<div class="flex items-baseline gap-2">
|
||||||
</div>
|
<span class="text-xs font-medium text-gray-700 dark:text-neutral-300 truncate">
|
||||||
</Show>
|
{props.mine ? "You" : name()}
|
||||||
|
|
||||||
<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
|
|
||||||
</span>
|
</span>
|
||||||
<For each={pendingDkg()}>
|
<span class="text-xs text-gray-400 dark:text-neutral-500">
|
||||||
{(session: DkgSession) => (
|
{formatTime(props.event.created_at)}
|
||||||
<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>
|
</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>
|
</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>
|
</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 { Show, Switch, Match } from "solid-js"
|
||||||
import { activeQuorum, view, setTab } from "../store"
|
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 QuorumLog from "./tabs/QuorumLog"
|
||||||
import QuorumMembers from "./tabs/QuorumMembers"
|
import QuorumMembers from "./tabs/QuorumMembers"
|
||||||
import QuorumChat from "./QuorumChat"
|
import QuorumChat from "./QuorumChat"
|
||||||
@@ -21,8 +24,14 @@ const badgeClass: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function QuorumDetail(props: Props) {
|
export default function QuorumDetail(props: Props) {
|
||||||
const quorumName = useProfileDisplay(() => activeQuorum()?.quorumPubkey ?? "")
|
const profile = useProfile(() => activeQuorum()?.quorumPubkey ?? "")
|
||||||
const title = () => (activeQuorum()?.quorumPubkey ? quorumName() : "New quorum")
|
// 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)
|
const complete = () => Boolean(activeQuorum()?.complete)
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
@@ -34,24 +43,21 @@ export default function QuorumDetail(props: Props) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col h-full">
|
<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="min-w-0">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<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">
|
<span class="text-sm font-semibold text-gray-900 dark:text-white truncate">
|
||||||
Quorum
|
{headerName()}
|
||||||
</span>
|
</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}
|
{activeQuorum()!.statusLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div class="font-mono text-xs text-gray-400 dark:text-neutral-500 break-all">
|
||||||
{activeQuorum()!.quorumPubkey ?? activeQuorum()!.inviteId}
|
{activeQuorum()!.quorumPubkey ?? activeQuorum()!.inviteId}
|
||||||
</div>
|
</div>
|
||||||
</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
|
<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"
|
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()}
|
disabled={!complete()}
|
||||||
@@ -85,7 +91,12 @@ export default function QuorumDetail(props: Props) {
|
|||||||
.join(" ")}
|
.join(" ")}
|
||||||
onClick={() => setTab(tab as Tab)}
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +111,7 @@ export default function QuorumDetail(props: Props) {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={(view() as any).tab === "chat"}>
|
<Match when={(view() as any).tab === "chat"}>
|
||||||
<div class="md:hidden h-full">
|
<div class="md:hidden h-full">
|
||||||
<QuorumChat />
|
<QuorumChat isVisible={() => !isDesktop()} />
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { For } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
import { displayedQuora, view, openQuorum } from "../store"
|
import { displayedQuora, view, openQuorum } from "../store"
|
||||||
import { useProfileDisplay } from "../hooks"
|
import { useProfileDisplay } from "../hooks"
|
||||||
|
import { quorumHasActivity } from "../engine/notifications"
|
||||||
import type { DisplayedQuorum } from "../models"
|
import type { DisplayedQuorum } from "../models"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onNew?: () => void
|
onNew?: () => void
|
||||||
|
onNavigate?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const dotClass: Record<DisplayedQuorum["status"], string> = {
|
const dotClass: Record<DisplayedQuorum["status"], string> = {
|
||||||
@@ -14,7 +16,7 @@ const dotClass: Record<DisplayedQuorum["status"], string> = {
|
|||||||
pending: "bg-gray-400 dark:bg-neutral-500",
|
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 name = useProfileDisplay(() => props.quorum.quorumPubkey ?? "")
|
||||||
const label = () => (props.quorum.quorumPubkey ? name() : "New quorum")
|
const label = () => (props.quorum.quorumPubkey ? name() : "New quorum")
|
||||||
const isActive = () => {
|
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"
|
? "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"
|
: "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">
|
<div class="font-medium text-sm truncate flex items-center gap-1.5">
|
||||||
{label()}
|
<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>
|
||||||
<div class="flex items-center gap-1.5 text-xs opacity-60">
|
<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]}`} />
|
<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>
|
</span>
|
||||||
<button
|
<button
|
||||||
class="text-gray-500 dark:text-neutral-400 hover:text-gray-700 dark:hover:text-neutral-200 text-lg leading-none transition-colors"
|
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>
|
</button>
|
||||||
@@ -67,7 +72,7 @@ export default function QuorumList(props: Props) {
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(q) => <QuorumListItem quorum={q} />}
|
{(q) => <QuorumListItem quorum={q} onNavigate={props.onNavigate} />}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</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 toast from "solid-toast"
|
||||||
import type { DkgSession } from "../../models"
|
import type { DkgSession } from "../../models"
|
||||||
import PubkeyInput from "../PubkeyInput"
|
import PubkeyInput from "../PubkeyInput"
|
||||||
import { createQuorum } from "../../quorum"
|
import { createQuorum, acceptDkg, declineDkg } from "../../engine/dkg"
|
||||||
import { useActivePubkey, useAutoThreshold } from "../../hooks"
|
import { useActivePubkey, useAutoThreshold } from "../../hooks"
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
@@ -93,19 +93,32 @@ export function ProposeQuorum(props: { onClose: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DkgInviteResponse(props: { session: DkgSession; 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() {
|
async function handleAccept() {
|
||||||
console.log("DkgInviteResponse accept", { inviteId: props.session.inviteId })
|
setSubmitting(true)
|
||||||
props.onClose()
|
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() {
|
async function handleDecline() {
|
||||||
console.log("DkgInviteResponse decline", {
|
setSubmitting(true)
|
||||||
inviteId: props.session.inviteId,
|
try {
|
||||||
reason: reason(),
|
await declineDkg(props.session.inviteId, message().trim() || undefined)
|
||||||
})
|
props.onClose()
|
||||||
props.onClose()
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to decline invite")
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -123,29 +136,31 @@ export function DkgInviteResponse(props: { session: DkgSession; onClose: () => v
|
|||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-neutral-300">
|
<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>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
class={inputClass}
|
class={inputClass}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Optional reason..."
|
placeholder="Optional message..."
|
||||||
value={reason()}
|
value={message()}
|
||||||
onInput={e => setReason(e.currentTarget.value)}
|
onInput={e => setMessage(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 justify-end">
|
<div class="flex gap-2 justify-end">
|
||||||
<button
|
<button
|
||||||
type="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}
|
onClick={handleDecline}
|
||||||
|
disabled={submitting()}
|
||||||
>
|
>
|
||||||
Decline
|
Decline
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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}
|
onClick={handleAccept}
|
||||||
|
disabled={submitting()}
|
||||||
>
|
>
|
||||||
Accept
|
Accept
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { createSignal, Show } from "solid-js"
|
import { createSignal, Show } from "solid-js"
|
||||||
|
import toast from "solid-toast"
|
||||||
import type { ResharingSession } from "../../models"
|
import type { ResharingSession } from "../../models"
|
||||||
import PubkeyInput from "../PubkeyInput"
|
import PubkeyInput from "../PubkeyInput"
|
||||||
import { validateMessagingRelays } from "../../lib/relays"
|
import { validateMessagingRelays } from "../../lib/relays"
|
||||||
import { useAutoThreshold, useProfileDisplay } from "../../hooks"
|
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 }) {
|
export function ProposeResharing(props: { quorumPubkey: string; onClose: () => void }) {
|
||||||
const [newMembers, setNewMembers] = createSignal<string[]>([])
|
const [newMembers, setNewMembers] = createSignal<string[]>([])
|
||||||
@@ -26,22 +29,36 @@ export function ProposeResharing(props: { quorumPubkey: string; onClose: () => v
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
// Contributors are the current quorum members who reshare their shards. Default to
|
||||||
const relayError = await validateMessagingRelays(members)
|
// all current members so the contributing set always meets the existing threshold.
|
||||||
setSubmitting(false)
|
const record = getQuorumRecord(props.quorumPubkey)
|
||||||
|
if (!record) {
|
||||||
if (relayError) {
|
setError("Quorum is not yet complete; cannot rotate keys.")
|
||||||
setError(relayError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const contributors = record.members.map(m => m.pubkey)
|
||||||
|
|
||||||
console.log("ProposeResharing", {
|
setSubmitting(true)
|
||||||
quorumPubkey: props.quorumPubkey,
|
try {
|
||||||
newMembers: members,
|
// Optimistic: a missing messaging relay is surfaced as a warning, but never blocks
|
||||||
newThreshold: t,
|
// 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 (
|
return (
|
||||||
@@ -101,15 +118,30 @@ export function ProposeResharing(props: { quorumPubkey: string; onClose: () => v
|
|||||||
|
|
||||||
export function ResharingInviteResponse(props: { session: ResharingSession; onClose: () => void }) {
|
export function ResharingInviteResponse(props: { session: ResharingSession; onClose: () => void }) {
|
||||||
const quorumName = useProfileDisplay(() => props.session.quorumPubkey)
|
const quorumName = useProfileDisplay(() => props.session.quorumPubkey)
|
||||||
|
const [submitting, setSubmitting] = createSignal(false)
|
||||||
|
|
||||||
function handleParticipate() {
|
async function handleParticipate() {
|
||||||
console.log("ResharingInviteResponse: participate", props.session.proposalId)
|
setSubmitting(true)
|
||||||
props.onClose()
|
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() {
|
async function handleDecline() {
|
||||||
console.log("ResharingInviteResponse: decline", props.session.proposalId)
|
setSubmitting(true)
|
||||||
props.onClose()
|
try {
|
||||||
|
await declineResharing(props.session.proposalId)
|
||||||
|
props.onClose()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to decline")
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -133,14 +165,16 @@ export function ResharingInviteResponse(props: { session: ResharingSession; onCl
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDecline}
|
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
|
Decline
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleParticipate}
|
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
|
Participate
|
||||||
</button>
|
</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 type { SigningSession } from "../../models"
|
||||||
import { displayedQuora } from "../../store"
|
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 }) {
|
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 [error, setError] = createSignal("")
|
||||||
const [submitting, setSubmitting] = createSignal(false)
|
const [submitting, setSubmitting] = createSignal(false)
|
||||||
|
|
||||||
const memberPubkeys = () =>
|
const quorum = createMemo(() =>
|
||||||
displayedQuora().find(q => q.quorumPubkey === props.quorumPubkey)?.members.map(m => m.pubkey) ?? []
|
displayedQuora().find(q => q.quorumPubkey === props.quorumPubkey))
|
||||||
|
|
||||||
async function handleSubmit() {
|
// Background-load the quorum's profile and relay lists; warn when it has no outbox relays,
|
||||||
if (!message().trim()) {
|
// since a signed event would then have nowhere to be published.
|
||||||
setError("Message is required.")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setError("")
|
setError("")
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
const relayError = await validateMessagingRelays(memberPubkeys())
|
try {
|
||||||
setSubmitting(false)
|
// Optimistic: the request is stored and shown immediately even if no member
|
||||||
|
// is reachable. requestSignature does not hard-fail on undeliverability.
|
||||||
if (relayError) {
|
await requestSignature(props.quorumPubkey, template)
|
||||||
setError(relayError)
|
props.onClose()
|
||||||
return
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to request signature")
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log({ quorumPubkey: props.quorumPubkey, message: message() })
|
return (
|
||||||
props.onClose()
|
<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 (
|
return (
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<h2 class="text-base font-semibold text-gray-900 dark:text-white">Signature Request</h2>
|
<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">
|
<div class="flex flex-col gap-1">
|
||||||
<span class="text-xs text-gray-500 dark:text-neutral-400">Message hash</span>
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<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">
|
<span class="text-sm text-gray-900 dark:text-white">
|
||||||
{props.session.signingSet.length} members
|
{collected()} / {threshold()} signers
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div class="flex gap-2 justify-end">
|
||||||
<button
|
<button
|
||||||
type="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={secondaryButtonClass}
|
||||||
onClick={props.onClose}
|
disabled={submitting()}
|
||||||
|
onClick={handleDecline}
|
||||||
>
|
>
|
||||||
Decline
|
Decline
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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={declineButtonClass}
|
||||||
onClick={props.onClose}
|
disabled={submitting()}
|
||||||
|
onClick={handleSign}
|
||||||
>
|
>
|
||||||
Sign
|
Sign
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,73 +1,28 @@
|
|||||||
import { createSignal, createEffect, onCleanup, For } from "solid-js"
|
import { createEffect, Show } from "solid-js"
|
||||||
import type { TrustedEvent, Filter } from "@welshman/util"
|
|
||||||
import { repository } from "@welshman/app"
|
|
||||||
import { deriveEvents } from "@welshman/store"
|
|
||||||
import { activeQuorum } from "../../store"
|
import { activeQuorum } from "../../store"
|
||||||
import { PROTOCOL_KINDS } from "../../protocol"
|
import { markTabViewed } from "../../engine/notifications"
|
||||||
|
import { dkgSessions } from "../../engine/dkg"
|
||||||
function kindLabel(kind: number): string {
|
import { signingSessions } from "../../engine/signing"
|
||||||
if (kind === 7050) { return "DKG Invite" }
|
import { resharingSessions } from "../../engine/resharing"
|
||||||
if (kind === 7051) { return "DKG Round 1" }
|
import QuorumSessions from "../QuorumSessions"
|
||||||
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`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function QuorumLog() {
|
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(() => {
|
createEffect(() => {
|
||||||
const quorum = activeQuorum()
|
const quorum = activeQuorum()
|
||||||
if (!quorum) { setEvents([]); return }
|
if (!quorum) { return }
|
||||||
// Completed quora tag events with their pubkey; a pending invite is referenced by
|
dkgSessions()
|
||||||
// its id (the invite event itself, plus round-1/decline events that "e"-tag it).
|
signingSessions()
|
||||||
const filters: Filter[] = quorum.quorumPubkey
|
resharingSessions()
|
||||||
? [{ kinds: PROTOCOL_KINDS, "#quorum": [quorum.quorumPubkey] }]
|
markTabViewed(quorum.inviteId, "log")
|
||||||
: [{ 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))))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<For
|
<Show when={activeQuorum()}>
|
||||||
each={events()}
|
{q => <QuorumSessions quorum={q()} />}
|
||||||
fallback={<p class="text-sm text-gray-400 dark:text-neutral-500">No events yet.</p>}
|
</Show>
|
||||||
>
|
|
||||||
{(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>
|
|
||||||
</div>
|
</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 { activeQuorum } from "../../store"
|
||||||
import { useProfileDisplay } from "../../hooks"
|
import { useProfileDisplay } from "../../hooks"
|
||||||
|
import { markTabViewed } from "../../engine/notifications"
|
||||||
import type { QuorumMember } from "../../protocol"
|
import type { QuorumMember } from "../../protocol"
|
||||||
|
|
||||||
function MemberRow(props: { member: QuorumMember; copied: boolean; onCopy: () => void }) {
|
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() {
|
export default function QuorumMembers() {
|
||||||
const [copiedIndex, setCopiedIndex] = createSignal<number | null>(null)
|
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) {
|
function copyPubkey(m: QuorumMember) {
|
||||||
navigator.clipboard.writeText(m.pubkey)
|
navigator.clipboard.writeText(m.pubkey)
|
||||||
setCopiedIndex(m.index)
|
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>
|
round2: Record<number, Hex>
|
||||||
/** Members who declined, keyed by pubkey, value is their optional reason */
|
/** Members who declined, keyed by pubkey, value is their optional reason */
|
||||||
declines: Record<Hex, string>
|
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 ─────────────────────────────────────────────────────────
|
// ── Resharing session ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
+9
-3
@@ -3,15 +3,21 @@ import { request } from "@welshman/net"
|
|||||||
type Subscription = { unsubscribe(): void }
|
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
|
* 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 {
|
export function subscribeInbox(relays: string[], pubkey: string): Subscription {
|
||||||
const ctrl = new AbortController()
|
const ctrl = new AbortController()
|
||||||
request({
|
request({
|
||||||
relays,
|
relays,
|
||||||
filters: [{ kinds: [1059], "#p": [pubkey], "#t": ["b7ed"] }],
|
filters: [{ kinds: [1059], "#p": [pubkey] }],
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
})
|
})
|
||||||
return { unsubscribe: () => ctrl.abort() }
|
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"
|
} from "@welshman/app"
|
||||||
import { subscribeInbox } from "./nostr"
|
import { subscribeInbox } from "./nostr"
|
||||||
import { assignIndices } from "./protocol"
|
import { assignIndices } from "./protocol"
|
||||||
|
import { quora as completedQuora } from "./engine/secrets"
|
||||||
import type {
|
import type {
|
||||||
QuorumRecord,
|
QuorumRecord,
|
||||||
DisplayedQuorum,
|
DisplayedQuorum,
|
||||||
@@ -24,7 +25,7 @@ import type {
|
|||||||
SigningSession,
|
SigningSession,
|
||||||
} from "./models"
|
} 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 {
|
export function logout(): void {
|
||||||
// dropSession removes the session and its cached signer entirely; the sync()
|
// dropSession removes the session and its cached signer entirely; the sync()
|
||||||
@@ -34,7 +35,7 @@ export function logout(): void {
|
|||||||
if (pk) { dropSession(pk) }
|
if (pk) { dropSession(pk) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const [view, setView] = createSignal<View>("inbox")
|
export const [view, setView] = createSignal<View>("home")
|
||||||
|
|
||||||
export function openQuorum(id: string): void {
|
export function openQuorum(id: string): void {
|
||||||
setView({ type: "quorum", id, tab: "log" })
|
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: [7061] }] }).subscribe(setDeclineEvents)
|
||||||
deriveEvents({ repository, filters: [{ kinds: [7053] }] }).subscribe(setCompleteEvents)
|
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>> {
|
function authorsByInviteId(events: TrustedEvent[]): Map<string, Set<string>> {
|
||||||
const m = new Map<string, Set<string>>()
|
const m = new Map<string, Set<string>>()
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
@@ -149,6 +156,29 @@ export const displayedQuora = createMemo<DisplayedQuorum[]>(() => {
|
|||||||
} satisfies 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)
|
return list.sort((a, b) => b.createdAt - a.createdAt)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -160,6 +190,16 @@ export const activeQuorum = createMemo<DisplayedQuorum | undefined>(() => {
|
|||||||
return 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 ────────────────────────────────────────────────────────
|
// ── Inbox subscription ────────────────────────────────────────────────────────
|
||||||
// One active subscription, restarted when the active pubkey or the user's
|
// One active subscription, restarted when the active pubkey or the user's
|
||||||
// kind-10050 relay list changes. Subscribing to userMessagingRelayList makes it
|
// 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,
|
// Eagerly fetch relay lists and profiles for every member of every visible quorum,
|
||||||
// so their names render in the list and detail views.
|
// so their names render in the list and detail views.
|
||||||
createEffect(() => {
|
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) {
|
for (const pk of pubkeys) {
|
||||||
loadRelayList(pk)
|
loadRelayList(pk)
|
||||||
loadMessagingRelayList(pk)
|
loadMessagingRelayList(pk)
|
||||||
|
|||||||
Reference in New Issue
Block a user