More fixes

This commit is contained in:
Jon Staab
2026-06-12 14:14:53 -07:00
parent 9dff41f096
commit af4d53269a
13 changed files with 294 additions and 111 deletions
+4 -4
View File
@@ -183,7 +183,7 @@ Each contributing member Pᵢ (index `i` from the original DKG, shard `xᵢ`):
5. Computes Schnorr PoK of `λᵢ · xᵢ` (same construction as Protocol 1 Round 1, using `"frost/resharing/round1"` as domain tag)
6. Sends to all new members (m gift wraps, identical payload):
6. Sends to **all other participants — every new member and every fellow contributor** (one gift wrap each, identical payload). New members consume Round 1 to verify and finalize; contributors must receive every contributor's Round 1 before sending Round 2, so a contributor who is rotated *out* of the new set (and therefore receives nothing addressed to "new members") still observes the complete contributing set and delivers its shard. It also lets contributors detect Round-1 equivocation among themselves.
```json
{
@@ -245,9 +245,9 @@ Each new member Qⱼ, after receiving shares from all members of `S`:
4. BIP-340 normalization: `Y` is unchanged, so the same even-y convention applies. If `xᵢ` was negated during the original DKG finalization, `hᵢ(0) = λᵢ · xᵢ` already incorporates that negation. Qⱼ verifies against the same `Y` and does not re-negate.
5. Replaces stored quorum state with `(x'ⱼ, Y, Y'ⱼ, new_members, new_threshold, Round-1 commitments from this session)`.
5. Replaces stored quorum state with `(x'ⱼ, Y, Y'ⱼ, new_members, new_threshold, this session's commitments)`. The stored commitment is the **group commitment** `G = Σᵢ∈S Dᵢ` (the coefficient-wise sum of the contributors' Round-1 commitments), not the per-contributor breakdown. The contributing set `S` rarely has the same size as the new member set, so a per-contributor list cannot be re-serialised as one `dkg_commit` tag per member without leaving empty tags (members added) or dropping commitments (members removed). Because every verifier only ever **sums over the commitment values** (`Σⱼ Σₖ i^k·Cⱼₖ` and `Σⱼ Cⱼ₀`), the single collapsed vector `G` reconstructs every member's verification share and the raw group key identically. On the next rotation it is carried in one non-empty `dkg_commit` tag (the remaining members' tags are empty and ignored).
6. Sends resharing confirmation (kind 7057) to all other n'1 new members.
6. Sends resharing confirmation (kind 7057) to **all other participants — every other new member and every contributor, including contributors rotated out of the new set**. A rotated-out contributor holds no new shard and never finalizes locally, so receiving the new members' confirmations is its only signal that the rotation succeeded (and how it records the rotation for chat gating).
### Resharing Confirmation (kind 7057)
@@ -383,7 +383,7 @@ Quorum state must be stored durably per-quorum:
| `verification_share` | `Yⱼ` — public verification share for this member |
| `members` | Current member pubkeys and indices |
| `threshold` | Current signing threshold `t` |
| `dkg_commitments` | All participants' Round-1 commitments from the most recent DKG or resharing session |
| `dkg_commitments` | The commitments that define the current sharing: after a DKG, all participants' Round-1 commitments (one per member); after a resharing, the single **group commitment** `Σᵢ∈S Dᵢ` (see Key Redistribution Finalization step 5) |
| `rotation_records` | Ordered list of completed rotation sessions, each stored as the quorum's kind 7057 confirmation set (all `t'` matching confirmations) |
`dkg_commitments` are retained permanently because they are required to verify shard authenticity during resharing (step 4 of the Key Redistribution Round 1).
+3 -2
View File
@@ -12,12 +12,13 @@ import Modal from "./components/Modal"
import { displayProfile } from "@welshman/util"
import { useActivePubkey, useProfile } from "./hooks"
import { isDesktop } from "./lib/media"
import { shortNpub } from "./lib/pubkey"
function SidebarContent(props: { onNew: () => void; onNavigate?: () => void }) {
const pubkey = useActivePubkey()
const shortKey = () => `${pubkey().slice(0, 8)}${pubkey().slice(-4)}`
// Fallback identity label when no profile name — a truncated npub, never raw hex.
const profile = useProfile(pubkey)
const name = () => displayProfile(profile(), shortKey())
const name = () => displayProfile(profile(), shortNpub(pubkey()))
return (
<div class="flex flex-col h-full">
+2 -31
View File
@@ -4,6 +4,7 @@ import { userMessagingRelayList, addMessagingRelay, removeMessagingRelay, waitFo
import { useReadable } from "../lib/stores"
import { useActivePubkey, useProfile } from "../hooks"
import Avatar from "./Avatar"
import Npub from "./Npub"
export default function AccountPage() {
const pubkey = useActivePubkey()
@@ -14,7 +15,6 @@ export default function AccountPage() {
const [newRelay, setNewRelay] = createSignal("")
const [publishing, setPublishing] = createSignal(false)
const [copied, setCopied] = createSignal(false)
const [addError, setAddError] = createSignal("")
async function addRelay() {
@@ -51,12 +51,6 @@ export default function AccountPage() {
}
}
async function copyPubkey() {
await navigator.clipboard.writeText(pubkey())
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<div class="max-w-lg mx-auto p-6 flex flex-col gap-6">
{/* Profile header */}
@@ -68,30 +62,7 @@ export default function AccountPage() {
{name()}
</p>
</Show>
<div class="flex items-center gap-1.5">
<span class="text-xs font-mono text-gray-500 dark:text-neutral-400 truncate max-w-56">
{pubkey()}
</span>
<button
title="Copy pubkey"
class="p-1 rounded text-gray-400 dark:text-neutral-500 hover:text-gray-600 dark:hover:text-neutral-300 transition-colors shrink-0"
onClick={copyPubkey}
>
<Show
when={copied()}
fallback={
<svg class="size-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
}
>
<svg class="size-3.5 text-green-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</Show>
</button>
</div>
<Npub pubkey={pubkey()} class="text-xs text-gray-500 dark:text-neutral-400 max-w-64" />
</div>
</div>
+4 -3
View File
@@ -4,6 +4,7 @@ import type { PublishedProfile } from "@welshman/util"
import { loadProfile, searchProfiles, profileSearch } from "@welshman/app"
import { useReadable } from "../lib/stores"
import { useProfileDisplay } from "../hooks"
import { shortNpub } from "../lib/pubkey"
import Avatar from "./Avatar"
interface Props {
@@ -150,10 +151,10 @@ export default function PubkeyInput(props: Props) {
<Avatar pubkey={r.event.pubkey} size="size-7" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
{displayProfile(r, r.event.pubkey.slice(0, 8) + "…")}
{displayProfile(r, shortNpub(r.event.pubkey))}
</p>
<p class="text-xs text-gray-400 dark:text-neutral-500 font-mono">
{r.event.pubkey.slice(0, 12)}
<p class="text-xs text-gray-400 dark:text-neutral-500 font-mono truncate">
{shortNpub(r.event.pubkey)}
</p>
</div>
</button>
+42 -20
View File
@@ -1,7 +1,7 @@
import { For, Show, createMemo, createSignal, createEffect } from "solid-js"
import type { TrustedEvent } from "@welshman/util"
import { toast } from "solid-toast"
import { activeQuorum } from "../store"
import { activeQuorum, membershipWindows, membersAt } from "../store"
import { useProfileDisplay, useActivePubkey } from "../hooks"
import { chatMessages, sendChatMessage } from "../engine/chat"
import { markTabViewed } from "../engine/notifications"
@@ -54,7 +54,20 @@ export default function QuorumChat(props: { isVisible?: () => boolean }) {
if (!q) { return [] }
return [q.inviteId, q.quorumPubkey].filter((x): x is string => Boolean(x))
})
const messages = createMemo<TrustedEvent[]>(() => chatMessages(threadIds()))
// Membership windows over the quorum's history, so a message is shown only if its sender was
// a member when it was sent — hides messages from rotated-out (or never-) members.
const windows = createMemo(() => {
const q = quorum()
return q ? membershipWindows(q.quorumPubkey, q.inviteId) : []
})
const messages = createMemo<TrustedEvent[]>(() => {
const ws = windows()
return chatMessages(threadIds()).filter(e => membersAt(ws, e.created_at).has(e.pubkey))
})
// A user who has been rotated out can no longer post (and anything they send would be
// filtered out for everyone anyway). isMember defaults true, so members chat normally.
const canChat = () => quorum()?.isMember ?? true
const [draft, setDraft] = createSignal("")
const [sending, setSending] = createSignal(false)
@@ -82,7 +95,7 @@ export default function QuorumChat(props: { isVisible?: () => boolean }) {
async function send() {
const q = quorum()
const text = draft().trim()
if (!q || !text || sending()) { return }
if (!q || !text || sending() || !canChat()) { return }
setSending(true)
try {
// Send under the established pubkey if it exists, otherwise the invite id.
@@ -132,23 +145,32 @@ export default function QuorumChat(props: { isVisible?: () => boolean }) {
</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>
<Show
when={canChat()}
fallback={
<div class="border-t border-gray-200 dark:border-neutral-700 p-3 text-center text-xs italic text-gray-400 dark:text-neutral-500">
You are no longer a member of this quorum and can't send messages.
</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>
</Show>
</div>
</Show>
)
+41 -9
View File
@@ -7,13 +7,14 @@ import { isDesktop } from "../lib/media"
import QuorumLog from "./tabs/QuorumLog"
import QuorumMembers from "./tabs/QuorumMembers"
import QuorumChat from "./QuorumChat"
import Npub from "./Npub"
type Props = {
onProposeSign?: () => void
onProposeResharing?: () => void
}
const TABS = ["log", "members", "chat"] as const
const TABS = ["log", "chat", "members"] as const
type Tab = (typeof TABS)[number]
const badgeClass: Record<string, string> = {
@@ -33,6 +34,13 @@ export default function QuorumDetail(props: Props) {
return displayProfile(profile(), "Quorum")
}
const complete = () => Boolean(activeQuorum()?.complete)
// Defaults to true; only false once a rotation has removed the active user from the quorum.
const isMember = () => activeQuorum()?.isMember ?? true
const canAct = () => complete() && isMember()
const actionTitle = () =>
!complete() ? "Available once the quorum is established"
: !isMember() ? "You are no longer a member of this quorum"
: ""
return (
<Show
when={activeQuorum()}
@@ -53,23 +61,31 @@ export default function QuorumDetail(props: Props) {
{activeQuorum()!.statusLabel}
</span>
</div>
<div class="font-mono text-xs text-gray-400 dark:text-neutral-500 break-all">
{activeQuorum()!.quorumPubkey ?? activeQuorum()!.inviteId}
</div>
<Show
when={activeQuorum()!.quorumPubkey}
fallback={
// No group key exists until the DKG completes, so there's no npub to show yet.
<span class="text-xs italic text-gray-400 dark:text-neutral-500">
Key not generated yet
</span>
}
>
{pk => <Npub pubkey={pk()} class="text-xs text-gray-400 dark:text-neutral-500 max-w-full" />}
</Show>
</div>
<div class="flex items-center gap-2 shrink-0 sm:ml-4">
<button
class="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
disabled={!complete()}
title={complete() ? "" : "Available once the quorum is established"}
disabled={!canAct()}
title={actionTitle()}
onClick={props.onProposeSign}
>
Request Signature
</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 disabled:opacity-40 disabled:cursor-not-allowed"
disabled={!complete()}
title={complete() ? "" : "Available once the quorum is established"}
disabled={!canAct()}
title={actionTitle()}
onClick={props.onProposeResharing}
>
Rotate Keys
@@ -77,6 +93,21 @@ export default function QuorumDetail(props: Props) {
</div>
</div>
<Show when={!isMember()}>
<div class="px-4 py-3 bg-red-50 dark:bg-red-900/30 border-b border-red-200 dark:border-red-800 flex items-start gap-2.5">
<svg class="size-5 shrink-0 text-red-500 dark:text-red-400 mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01M5.07 19h13.86a2 2 0 001.74-3L13.74 4a2 2 0 00-3.48 0L3.33 16a2 2 0 001.74 3z" />
</svg>
<div class="min-w-0">
<p class="text-sm font-semibold text-red-700 dark:text-red-300">You are no longer a member of this quorum</p>
<p class="text-xs text-red-600 dark:text-red-400 mt-0.5">
A key rotation removed you. You can still view its history, but you can no longer request
signatures, rotate keys, or sign and other members will ignore any requests you send.
</p>
</div>
</div>
</Show>
<div class="border-b border-gray-200 dark:border-neutral-700 px-4 flex">
{TABS.map(tab => (
<button
@@ -93,7 +124,8 @@ export default function QuorumDetail(props: Props) {
>
<span class="inline-flex items-center gap-1.5">
{tab.charAt(0).toUpperCase() + tab.slice(1)}
<Show when={activeQuorum() && tabHasActivity(activeQuorum()!, tab)}>
{/* The members tab has no "new activity" notion — never badge it. */}
<Show when={tab !== "members" && activeQuorum() && tabHasActivity(activeQuorum()!, tab)}>
<span class="size-1.5 rounded-full bg-blue-500" title="New activity" />
</Show>
</span>
+5 -26
View File
@@ -1,10 +1,11 @@
import { createSignal, createEffect, Show, For } from "solid-js"
import { createEffect, Show, For } from "solid-js"
import { activeQuorum } from "../../store"
import { useProfileDisplay } from "../../hooks"
import { markTabViewed } from "../../engine/notifications"
import Npub from "../Npub"
import type { QuorumMember } from "../../protocol"
function MemberRow(props: { member: QuorumMember; copied: boolean; onCopy: () => void }) {
function MemberRow(props: { member: QuorumMember }) {
const name = useProfileDisplay(() => props.member.pubkey)
return (
<div class="flex items-center gap-3 px-3 py-2 rounded-lg border border-gray-200 dark:border-neutral-600 bg-white dark:bg-neutral-800">
@@ -13,35 +14,19 @@ function MemberRow(props: { member: QuorumMember; copied: boolean; onCopy: () =>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{name()}</p>
<p class="text-xs font-mono text-gray-400 dark:text-neutral-500 truncate">
{props.member.pubkey.slice(0, 12)}{props.member.pubkey.slice(-8)}
</p>
<Npub pubkey={props.member.pubkey} class="text-xs text-gray-400 dark:text-neutral-500" />
</div>
<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.onCopy}
>
{props.copied ? "Copied!" : "Copy"}
</button>
</div>
)
}
export default function QuorumMembers() {
const [copiedIndex, setCopiedIndex] = createSignal<number | null>(null)
// Mark the members tab read while it is on screen.
createEffect(() => {
const q = activeQuorum()
if (q) { markTabViewed(q.inviteId, "members") }
})
function copyPubkey(m: QuorumMember) {
navigator.clipboard.writeText(m.pubkey)
setCopiedIndex(m.index)
setTimeout(() => setCopiedIndex(null), 1500)
}
return (
<div class="p-4">
<Show when={activeQuorum()}>
@@ -54,13 +39,7 @@ export default function QuorumMembers() {
</div>
<div class="space-y-2">
<For each={quorum().members}>
{(member) => (
<MemberRow
member={member}
copied={copiedIndex() === member.index}
onCopy={() => copyPubkey(member)}
/>
)}
{(member) => <MemberRow member={member} />}
</For>
</div>
</div>
+3 -3
View File
@@ -1,6 +1,6 @@
import { createSignal } from "solid-js"
import { toast } from "solid-toast"
import { pubkey, signer, loadMessagingRelayList } from "@welshman/app"
import { pubkey, signer, loadMessagingRelayList, displayProfileByPubkey } from "@welshman/app"
import { getTagValue } from "@welshman/util"
import type { TrustedEvent } from "@welshman/util"
import {
@@ -267,12 +267,12 @@ async function advanceDkg(s: DkgSession): Promise<void> {
// 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)}`)
toast.error(`Invalid round-1 (wrong threshold) from ${displayProfileByPubkey(m.pubkey)}`)
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)}`)
toast.error(`Invalid round-1 proof from ${displayProfileByPubkey(m.pubkey)}`)
return
}
}
+5 -1
View File
@@ -7,6 +7,7 @@ import type { TrustedEvent } from "@welshman/util"
import { PROTOCOL_KINDS } from "../protocol"
import type { DisplayedQuorum } from "../models"
import { trackForHmr } from "../lib/hmr"
import { membershipWindows, membersAt } from "../store"
// ── Notification checkpoints ────────────────────────────────────────────────────
// A "checkpoint" is the timestamp up to which a quorum's tab has been read. A tab has
@@ -95,9 +96,12 @@ function protocolEventsFor(q: DisplayedQuorum): TrustedEvent[] {
function chatEventsFor(q: DisplayedQuorum): TrustedEvent[] {
const ids = new Set([q.inviteId, q.quorumPubkey].filter((x): x is string => Boolean(x)))
// Same membership gating as the chat view, so a rotated-out member's messages don't light up
// the chat badge for a message that won't be displayed.
const windows = membershipWindows(q.quorumPubkey, q.inviteId)
return allChat().filter(e => {
const t = getTagValue("quorum", e.tags)
return t !== undefined && ids.has(t)
return t !== undefined && ids.has(t) && membersAt(windows, e.created_at).has(e.pubkey)
})
}
+80 -7
View File
@@ -1,6 +1,6 @@
import { createSignal } from "solid-js"
import { toast } from "solid-toast"
import { pubkey, signer, repository, loadMessagingRelayList } from "@welshman/app"
import { pubkey, signer, repository, loadMessagingRelayList, displayProfileByPubkey } from "@welshman/app"
import { getTagValue } from "@welshman/util"
import type { TrustedEvent } from "@welshman/util"
import {
@@ -47,6 +47,7 @@ import {
import { sendProtocolEvent, deliverTo } from "./delivery"
import { sendChatMessage } from "./chat"
import { trackForHmr, trackEffect } from "../lib/hmr"
import { schnorr } from "@noble/curves-v2/secp256k1.js"
// ── Local helpers ─────────────────────────────────────────────────────────────
@@ -54,6 +55,31 @@ const transcriptOf = (e: TrustedEvent): string | undefined => getTagValue("trans
const unique = <T,>(xs: T[]): T[] => Array.from(new Set(xs))
/**
* Coefficient-wise sum of Feldman commitment vectors → the single GROUP commitment vector
* `G[k] = Σᵢ Dᵢ[k]`. After a rotation the sharing is defined by `Σᵢ∈S Dᵢ` (the contributors'
* round-1 commitments); we store THAT sum, not the per-contributor breakdown, as the quorum's
* `commitments`. Why: the stored set is re-serialised as one `dkg_commit` tag per member for the
* next rotation, but the contributor count rarely equals the member count — adding members
* leaves empty tags (which crash `pt(cs[0])`), removing members drops commitments. Collapsing to
* the group vector means exactly one (non-empty) defining polynomial regardless of member count,
* and verification only ever sums the commitment VALUES, so every member's verification share is
* reconstructed identically. (`@noble` curve math, mirroring `src/protocol.ts`'s primitives,
* which we must not modify.)
*/
function groupCommitment(vectors: Hex[][]): Hex[] {
const width = Math.max(0, ...vectors.map(v => v.length))
const out: Hex[] = []
for (let k = 0; k < width; k++) {
let acc = schnorr.Point.ZERO
for (const v of vectors) {
if (v[k]) { acc = acc.add(schnorr.Point.fromHex(v[k])) }
}
out.push(acc.toHex(true))
}
return out
}
/**
* 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
@@ -62,6 +88,8 @@ const unique = <T,>(xs: T[]): T[] => Array.from(new Set(xs))
export type ResharingSessionView = ResharingSession & {
/** The initiator (kind 7054 author) */
initiator: Hex
/** The proposal's (kind 7054) created_at — used to order rotations chronologically. */
createdAt: number
iAmContributor: boolean
iAmNewMember: boolean
iAmInitiator: boolean
@@ -194,6 +222,7 @@ function deriveResharingViews(me: string): ResharingSessionView[] {
round2,
declines,
initiator,
createdAt: p.created_at,
iAmContributor,
iAmNewMember,
iAmInitiator,
@@ -245,6 +274,24 @@ export function resharingSessions(): ResharingSessionView[] {
return deriveResharingViews(me)
}
/**
* Authoritative CURRENT membership of a quorum, accounting for rotations: the new member set
* and threshold of the most recently COMPLETED rotation, or `undefined` if none has completed
* (caller should fall back to the original DKG membership). Derived from the resharing session
* views — which are reconstructed from shared 7054/7057 events — so it is correct for every
* participant, including members who were rotated OUT (whose stored QuorumRecord is stale, as
* they never run finalize) and brand-new members. Each rotation's `member` tags are absolute
* (not a delta), so the latest completed rotation alone defines the membership.
*/
export function currentMembership(quorumPubkey: string): { members: string[]; threshold: number } | undefined {
let latest: ResharingSessionView | undefined
for (const s of resharingSessions()) {
if (s.quorumPubkey !== quorumPubkey || s.phase !== "complete") { continue }
if (!latest || s.createdAt > latest.createdAt) { latest = s }
}
return latest ? { members: latest.newMembers, threshold: latest.newThreshold } : undefined
}
function findSession(proposalId: string): ResharingSessionView | undefined {
return resharingSessions().find(s => s.proposalId === proposalId)
}
@@ -420,9 +467,15 @@ async function advance(s: ResharingSessionView, me: Hex): Promise<void> {
const currentIndexByPubkey = new Map(currentMembers.map(m => [m.pubkey, m.index]))
// originalCommitments: Record<originalIndex, Hex[]> — used to verify each contributor's shard.
// SKIP empty entries: once the stored set has fewer commitment vectors than members (always
// true after a member-count-changing rotation, where it's a single GROUP vector), the spare
// members carry an empty ["dkg_commit", pk] tag. Verification sums over the VALUES (keys are
// irrelevant to the sum), so dropping empties yields the correct set — and, crucially, avoids
// verifyResharingRound1 throwing on `pt(cs[0])` for an empty vector (the round-2 stall).
const originalCommitments: Record<number, Hex[]> = {}
for (const m of currentMembers) {
originalCommitments[m.index] = s.dkgCommitsByPk[m.pubkey]
const cs = s.dkgCommitsByPk[m.pubkey]
if (cs && cs.length > 0) { originalCommitments[m.index] = cs }
}
// contributorSet: original DKG indices of S, sorted (passed to lagrangeCoeff in protocol.ts).
@@ -469,7 +522,15 @@ async function advance(s: ResharingSessionView, me: Hex): Promise<void> {
ownIndex: myOriginalIndex,
})
const recipients = s.newMembers.filter(pk => pk !== me)
// Round 1 goes to ALL participants — new members AND fellow contributors — not just
// new members. A contributor only advances to round 2 once it has every contributor's
// round 1 (STEP B's `allContributorsPresent` gate). A contributor who is rotated OUT
// (not a new member) would otherwise never receive its peers' round 1 — they'd only be
// sent to the new set — so it would stall before round 2 and never deliver its shard to
// the new members, freezing them at "Finalizing". New-only members also need it to
// verify; retained members are in both sets. (Spec §Round 1 lists new members as the
// consumers, but contributors need it too for the gate + equivocation detection.)
const recipients = unique([...s.contributors, ...s.newMembers]).filter(pk => pk !== me)
await sendProtocolEvent(rumor, recipients)
} catch (e) {
console.error("resharing round 1 failed", e)
@@ -499,7 +560,7 @@ async function advance(s: ResharingSessionView, me: Hex): Promise<void> {
)
if (!ok) {
patchProgress(s.proposalId, { aborted: true })
toast.error(`Rotation aborted: invalid commitment from ${c.slice(0, 8)}`)
toast.error(`Rotation aborted: invalid commitment from ${displayProfileByPubkey(c)}`)
return
}
}
@@ -583,8 +644,14 @@ async function advance(s: ResharingSessionView, me: Hex): Promise<void> {
return
}
// Persist the new quorum state, then append this rotation to the record.
await saveQuorum(result.state)
// Persist the new quorum state, then append this rotation to the record. Collapse the
// per-contributor commitments into the single GROUP commitment before storing, so the
// NEXT rotation always has exactly the polynomial(s) that define the sharing regardless
// of the member count (see groupCommitment). resharingFinalize returns the per-
// contributor map in `state.commitments`; we override it (the crypto is unchanged — the
// group vector reconstructs identical verification shares).
const state = { ...result.state, commitments: { 1: groupCommitment(Object.values(contributorCommitments)) } }
await saveQuorum(state)
appendRotationRecord(s.quorumPubkey, s.proposalId)
// Mark finalized + sentConfirm BEFORE network IO.
@@ -594,7 +661,13 @@ async function advance(s: ResharingSessionView, me: Hex): Promise<void> {
ownIndex: myNewIndex,
})
const recipients = s.newMembers.filter(pk => pk !== me)
// Confirm to EVERYONE — new members AND contributors who were rotated out. The
// completion criterion is still "all new members confirmed" (hasCompleteConfirmation
// checks the new set), but a rotated-out contributor / initiator holds no new shard
// and never finalizes locally: its only signal that the rotation succeeded is
// receiving the new members' confirmations (STEP E latches `finalized` from them).
// Sending only to new members left those members stuck showing the rotation pending.
const recipients = unique([...s.contributors, ...s.newMembers]).filter(pk => pk !== me)
await sendProtocolEvent(result.rumor, recipients)
} catch (e) {
console.error("resharing finalize/confirm failed", e)
+13 -3
View File
@@ -117,8 +117,16 @@ function deriveSigningSessions(
const quorum = getQuorumRecord(quorumPubkey)
if (!quorum) { continue }
// Only participate in quora we belong to.
if (!quorum.members.some(m => m.pubkey === mine)) { continue }
// The CURRENT member set. Our QuorumRecord reflects the post-rotation membership once we've
// finalized, so this set already excludes anyone rotated out.
const memberSet = new Set(quorum.members.map(m => m.pubkey))
// Only participate in quora we currently belong to.
if (!memberSet.has(mine)) { continue }
// Ignore requests from non-members. After a rotation a removed member can still BROADCAST a
// 7058, but it is not a legitimate request: don't surface it and don't sign it. (Their own
// client still shows it because their stored record is stale — the warning + disabled
// actions in QuorumDetail handle their side.)
if (!memberSet.has(req.pubkey)) { continue }
const p = getProgress(requestId)
@@ -130,10 +138,12 @@ function deriveSigningSessions(
continue
}
// Round-1 nonces (dedup newest-per-author), keyed by sender pubkey.
// Round-1 nonces (dedup newest-per-author), keyed by sender pubkey. Nonces from non-members
// are ignored so they can't be counted toward the threshold or pulled into the signing set.
const nonceByAuthor = latestByAuthor(noncesBySession.get(requestId) ?? [])
const round1: Record<Hex, { D: Hex; E: Hex }> = {}
for (const [pk, e] of nonceByAuthor) {
if (!memberSet.has(pk)) { continue }
const n = noncesOf(e)
if (n) { round1[pk] = n }
}
+3
View File
@@ -40,9 +40,12 @@ export type DisplayedQuorum = {
quorumPubkey?: Hex
/** The kind 7050 invite id (always set; the origin of the quorum) */
inviteId: Hex
/** CURRENT members — reflects completed rotations, not just the original invite. */
members: QuorumMember[]
threshold: number
complete: boolean
/** Whether the active user is a current member (false once they've been rotated out). */
isMember: boolean
status: QuorumStatusKind
statusLabel: string
/** Members who have broadcast round 1 (the creator always counts) */
+89 -2
View File
@@ -17,6 +17,8 @@ import { subscribeInbox } from "./nostr"
import { assignIndices } from "./protocol"
import { trackForHmr, trackEffect } from "./lib/hmr"
import { quora as completedQuora } from "./engine/secrets"
import { currentMembership, resharingSessions as resharingSessionViews } from "./engine/resharing"
import type { ResharingSessionView } from "./engine/resharing"
import type {
QuorumRecord,
DisplayedQuorum,
@@ -121,10 +123,15 @@ export const displayedQuora = createMemo<DisplayedQuorum[]>(() => {
const list = inviteEvents()
.filter(inv => inv.pubkey === mine || getTagValues("member", inv.tags).includes(mine))
.map(inv => {
const members = getTagValues("member", inv.tags)
const threshold = Number(getTagValue("threshold", inv.tags) ?? "1") || 1
const quorumPubkey = completed.get(inv.id)
const complete = Boolean(quorumPubkey)
// CURRENT membership: a completed rotation's new set supersedes the invite's. The invite
// tags only describe the ORIGINAL DKG, so without this a rotated quorum keeps showing the
// pre-rotation members/threshold and never marks a rotated-out user as no longer a member.
const rotated = quorumPubkey ? currentMembership(quorumPubkey) : undefined
const members = rotated?.members ?? getTagValues("member", inv.tags)
const threshold = rotated?.threshold ?? (Number(getTagValue("threshold", inv.tags) ?? "1") || 1)
const isMember = members.includes(mine)
// The creator has implicitly joined; others count once they broadcast round 1.
const joined = new Set([inv.pubkey, ...(joinedByInvite.get(inv.id) ?? [])])
const declined = declinedByInvite.get(inv.id)?.size ?? 0
@@ -153,6 +160,7 @@ export const displayedQuora = createMemo<DisplayedQuorum[]>(() => {
members: assignIndices(members),
threshold,
complete,
isMember,
status,
statusLabel,
joined: joined.size,
@@ -176,6 +184,9 @@ export const displayedQuora = createMemo<DisplayedQuorum[]>(() => {
members: record.members,
threshold: record.threshold,
complete: true,
// These standalone records belong to current members (we hold a finalized shard), so
// the active user is by definition still a member.
isMember: record.members.some(m => m.pubkey === mine),
status: "complete",
statusLabel: "Complete",
joined: record.members.length,
@@ -184,6 +195,38 @@ export const displayedQuora = createMemo<DisplayedQuorum[]>(() => {
} satisfies DisplayedQuorum)
}
// New members joining via rotation. A brand-new member has no original invite (they weren't
// in the DKG) and no record until they finalize — so the quorum would be invisible, leaving
// them unable to even open it to ACCEPT the rotation. Surface it from the resharing proposal
// (where they appear as a new member). Placed after the record merge so a finalized record
// wins; once they finalize, this pending "Joining…" entry is superseded by their record.
const joinByPubkey = new Map<string, ResharingSessionView>()
for (const s of resharingSessionViews()) {
if (!s.iAmNewMember || !s.quorumPubkey || s.aborted) { continue }
const prev = joinByPubkey.get(s.quorumPubkey)
if (!prev || s.createdAt > prev.createdAt) { joinByPubkey.set(s.quorumPubkey, s) }
}
for (const [quorumPubkey, s] of joinByPubkey) {
if (shownPubkeys.has(quorumPubkey)) { continue }
shownPubkeys.add(quorumPubkey)
list.push({
id: quorumPubkey,
quorumPubkey,
inviteId: s.proposalId,
members: assignIndices(s.newMembers),
threshold: s.newThreshold,
// The group key already exists, but we hold no shard until we finalize, so treat it as not
// yet established for us (action buttons stay disabled until the join completes).
complete: false,
isMember: true,
status: "pending",
statusLabel: "Joining…",
joined: s.newMembers.length,
declined: 0,
createdAt: s.createdAt,
} satisfies DisplayedQuorum)
}
return list.sort((a, b) => b.createdAt - a.createdAt)
})
@@ -195,6 +238,50 @@ export const activeQuorum = createMemo<DisplayedQuorum | undefined>(() => {
return undefined
})
// ── Membership history (chat gating) ──────────────────────────────────────────
// Who was a member of a quorum at any given moment, so chat messages from someone who was
// NOT a member at send time (e.g. a member who has since been rotated out, or a never-member)
// can be hidden — PROTOCOL.md §rotation_records.
export type MembershipWindow = { from: number; members: Set<string> }
/**
* Ordered membership windows for a quorum (ascending by `from`). Window 0 spans all time from 0
* with the original DKG/invite members; each COMPLETED rotation opens a new window from its
* proposal time carrying that rotation's new member set. The kind-14 rumor's `created_at` is the
* real send time (NIP-17 only randomises the outer 1059 wrap, not the inner rumor), so gating a
* message on it is accurate.
*/
export function membershipWindows(quorumPubkey: string | undefined, inviteId: string): MembershipWindow[] {
const rotations = quorumPubkey
? resharingSessionViews()
.filter(s => s.quorumPubkey === quorumPubkey && s.phase === "complete")
.sort((a, b) => a.createdAt - b.createdAt)
: []
// Original members: the invite's member tags. If we joined via rotation and hold no local
// invite, fall back to the earliest rotation's pre-rotation set (its dkg_commit pubkeys).
const invite = inviteEvents().find(e => e.id === inviteId)
const initial = invite
? getTagValues("member", invite.tags)
: rotations.length ? Object.keys(rotations[0].dkgCommitsByPk) : []
const windows: MembershipWindow[] = [{ from: 0, members: new Set(initial) }]
for (const r of rotations) {
windows.push({ from: r.createdAt, members: new Set(r.newMembers) })
}
return windows
}
/** The active member set at time `at` — the last window whose `from <= at`. */
export function membersAt(windows: MembershipWindow[], at: number): Set<string> {
let active = windows.length ? windows[0].members : new Set<string>()
for (const w of windows) {
if (w.from <= at) { active = w.members } else { break }
}
return active
}
// 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).