Add protocol impl and app scaffold

This commit is contained in:
Jon Staab
2026-06-08 10:56:55 -07:00
parent 42c5d2c23d
commit 19c741684c
19 changed files with 1309 additions and 51 deletions
+10 -1
View File
@@ -1,3 +1,12 @@
import { Show } from "solid-js"
import { account } from "./store"
import Login from "./Login"
import Layout from "./Layout"
export default function App() {
return <div class="p-4">Quorum</div>;
return (
<Show when={account()} fallback={<Login />}>
<Layout />
</Show>
)
}
+174
View File
@@ -0,0 +1,174 @@
import { For, Show, createSignal, onMount } from "solid-js"
import { account, logout, view, setView, type View } from "./store"
// Placeholder quorum list — will be replaced with real data
const MOCK_QUORUMS: { id: string; name: string }[] = []
function QuorumList() {
return (
<For each={MOCK_QUORUMS} fallback={
<p class="text-xs text-gray-400 dark:text-neutral-500 px-3 py-2">No quora yet</p>
}>
{q => (
<button
class={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
JSON.stringify(view()) === JSON.stringify({ type: "quorum", id: q.id })
? "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({ type: "quorum", id: q.id })}
>
{q.name}
</button>
)}
</For>
)
}
function SidebarContent() {
const pubkey = () => account()?.pubkey ?? ""
const shortKey = () => `${pubkey().slice(0, 8)}${pubkey().slice(-4)}`
return (
<div class="flex flex-col h-full">
{/* Inbox */}
<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>
{/* Quorums */}
<div class="px-3 py-2 flex-1 overflow-y-auto">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-gray-400 dark:text-neutral-500 uppercase tracking-wider px-3">
Quora
</span>
<button
class="p-1 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="Create quorum"
onClick={() => { /* TODO: open create modal */ }}
>
<svg class="size-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<QuorumList />
</div>
{/* User profile + logout */}
<div class="border-t border-gray-200 dark:border-neutral-700 p-3">
<div class="flex items-center gap-2">
<div class="size-8 rounded-full bg-gray-200 dark:bg-neutral-600 shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{shortKey()}</p>
</div>
<button
class="p-1.5 rounded-md text-gray-400 hover:text-gray-600 dark:text-neutral-500 dark:hover:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-700 transition-colors"
title="Log out"
onClick={() => logout()}
>
<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" />
</svg>
</button>
</div>
</div>
</div>
)
}
function MainContent() {
const v = view()
if (v === "inbox") {
return (
<div class="p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Inbox</h2>
<p class="text-sm text-gray-500 dark:text-neutral-400">No pending items.</p>
</div>
)
}
return (
<div class="p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Quorum</h2>
<p class="text-sm text-gray-500 dark:text-neutral-400">Quorum detail coming soon.</p>
</div>
)
}
export default function Layout() {
const [drawerOpen, setDrawerOpen] = createSignal(false)
return (
<div class="flex h-screen bg-gray-50 dark:bg-neutral-900 overflow-hidden">
{/* Desktop sidebar */}
<aside class="hidden md:flex flex-col w-64 shrink-0 border-r border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800">
<div class="px-4 py-4 border-b border-gray-200 dark:border-neutral-700">
<span class="text-base font-bold text-gray-900 dark:text-white">NQ</span>
</div>
<SidebarContent />
</aside>
{/* Mobile drawer overlay */}
<Show when={drawerOpen()}>
<div
class="fixed inset-0 z-40 bg-black/40 md:hidden"
onClick={() => setDrawerOpen(false)}
/>
<aside class="fixed inset-y-0 left-0 z-50 w-64 flex flex-col bg-white dark:bg-neutral-800 shadow-xl md:hidden">
<div class="px-4 py-4 border-b border-gray-200 dark:border-neutral-700 flex items-center justify-between">
<span class="text-base font-bold text-gray-900 dark:text-white">NQ</span>
<button
class="p-1 rounded-md text-gray-400 hover:text-gray-600"
onClick={() => setDrawerOpen(false)}
>
<svg class="size-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<SidebarContent />
</aside>
</Show>
{/* Main area */}
<div class="flex flex-col flex-1 min-w-0 overflow-hidden">
{/* Mobile top bar */}
<header class="md:hidden flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800">
<button
class="p-1.5 rounded-md text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700"
onClick={() => setDrawerOpen(true)}
>
<svg class="size-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<span class="text-base font-semibold text-gray-900 dark:text-white">NQ</span>
<div class="flex-1" />
<button
class="p-1.5 rounded-md text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700"
title="Create quorum"
onClick={() => { /* TODO: open create modal */ }}
>
<svg class="size-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
</button>
</header>
<main class="flex-1 overflow-y-auto">
<MainContent />
</main>
</div>
</div>
)
}
+138
View File
@@ -0,0 +1,138 @@
import { createSignal, Show } from "solid-js"
import toast from "solid-toast"
import { ExtensionAccount } from "applesauce-accounts/accounts/extension-account"
import { PrivateKeyAccount } from "applesauce-accounts/accounts/private-key-account"
import { login } from "./store"
type Tab = "extension" | "nsec" | "nip46"
export default function Login() {
const [tab, setTab] = createSignal<Tab>("extension")
const [nsec, setNsec] = createSignal("")
const [bunker, setBunker] = createSignal("")
const [loading, setLoading] = createSignal(false)
async function loginWithExtension() {
setLoading(true)
try {
const acc = await ExtensionAccount.fromExtension()
login(acc)
} catch (e: any) {
toast.error(e?.message ?? "Extension not found")
} finally {
setLoading(false)
}
}
async function loginWithNsec() {
const key = nsec().trim()
if (!key) { return }
setLoading(true)
try {
const acc = PrivateKeyAccount.fromKey(key)
login(acc)
} catch (e: any) {
toast.error(e?.message ?? "Invalid key")
} finally {
setLoading(false)
}
}
async function loginWithBunker() {
const uri = bunker().trim()
if (!uri) { return }
setLoading(true)
try {
// NIP-46 setup handled by caller once signer is wired
toast.error("NIP-46 not yet implemented")
} catch (e: any) {
toast.error(e?.message ?? "Connection failed")
} finally {
setLoading(false)
}
}
return (
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900 p-4">
<div class="w-full max-w-md">
<h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-2">NQ</h1>
<p class="text-sm text-center text-gray-500 dark:text-neutral-400 mb-8">
FROST multisig for Nostr keys
</p>
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-sm border border-gray-200 dark:border-neutral-700 overflow-hidden">
{/* Tabs */}
<div class="flex border-b border-gray-200 dark:border-neutral-700">
{(["extension", "nsec", "nip46"] as Tab[]).map(t => (
<button
class={`flex-1 py-3 text-sm font-medium transition-colors ${
tab() === t
? "text-blue-600 border-b-2 border-blue-600 dark:text-blue-400 dark:border-blue-400"
: "text-gray-500 hover:text-gray-700 dark:text-neutral-400 dark:hover:text-neutral-200"
}`}
onClick={() => setTab(t)}
>
{t === "extension" ? "Extension" : t === "nsec" ? "Private Key" : "NIP-46"}
</button>
))}
</div>
<div class="p-6">
<Show when={tab() === "extension"}>
<p class="text-sm text-gray-600 dark:text-neutral-300 mb-4">
Connect with a NIP-07 browser extension such as Alby or nos2x.
</p>
<button
class="w-full py-2.5 px-4 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
disabled={loading()}
onClick={loginWithExtension}
>
{loading() ? "Connecting…" : "Connect Extension"}
</button>
</Show>
<Show when={tab() === "nsec"}>
<p class="text-sm text-gray-600 dark:text-neutral-300 mb-4">
Enter your nsec or hex private key. The key is never sent anywhere.
</p>
<input
type="password"
placeholder="nsec1… or hex"
class="w-full px-3 py-2 mb-3 text-sm border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
value={nsec()}
onInput={e => setNsec(e.currentTarget.value)}
/>
<button
class="w-full py-2.5 px-4 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
disabled={loading() || !nsec().trim()}
onClick={loginWithNsec}
>
{loading() ? "Loading…" : "Log In"}
</button>
</Show>
<Show when={tab() === "nip46"}>
<p class="text-sm text-gray-600 dark:text-neutral-300 mb-4">
Paste a bunker:// URI from a remote signer.
</p>
<input
type="text"
placeholder="bunker://…"
class="w-full px-3 py-2 mb-3 text-sm border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
value={bunker()}
onInput={e => setBunker(e.currentTarget.value)}
/>
<button
class="w-full py-2.5 px-4 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
disabled={loading() || !bunker().trim()}
onClick={loginWithBunker}
>
{loading() ? "Connecting…" : "Connect"}
</button>
</Show>
</div>
</div>
</div>
</div>
)
}
View File
+2 -1
View File
@@ -1,2 +1,3 @@
@import "tailwindcss";
@plugin "preline/plugin";
@source "../node_modules/preline/dist/*.js";
@import "../node_modules/preline/variants.css";
+77
View File
@@ -0,0 +1,77 @@
import { RelayPool } from "applesauce-relay"
import { EventStore } from "applesauce-core"
import type { NostrEvent } from "applesauce-core/helpers/event"
import type { ISigner } from "applesauce-signers"
import { parseJson } from "@welshman/lib"
export const eventStore = new EventStore()
// Rumors are unsigned inner events — disable sig verification so they store cleanly.
eventStore.verifyEvent = undefined
export function addEvent(event: NostrEvent): void {
eventStore.add(event)
}
// Shape of the object returned by Observable.subscribe()
type Subscription = { unsubscribe(): void }
export const pool = new RelayPool()
export const INBOX_RELAYS: string[] = []
// Protocol event kinds produced by protocol.ts (inner rumor kinds)
const PROTOCOL_KIND_MIN = 7050
const PROTOCOL_KIND_MAX = 7061
export function subscribeInbox(
relays: string[],
pubkey: string,
signer: ISigner,
): Subscription {
if (!signer.nip44) {
throw new Error("Signer does not support NIP-44 encryption")
}
const subscription = pool
.subscription(relays, { kinds: [1059], "#p": [pubkey], "#t": ["b7ed"] })
.subscribe({
next: async (wrap: NostrEvent) => {
try {
const sealJson = await signer.nip44!.decrypt(wrap.pubkey, wrap.content)
const seal = parseJson<NostrEvent>(sealJson)
if (!seal) {
return
}
const rumorJson = await signer.nip44!.decrypt(seal.pubkey, seal.content)
const rumor = parseJson<NostrEvent>(rumorJson)
if (!rumor) {
return
}
// NIP-59: the rumor's claimed author must match the seal's authenticated author.
// The seal is MAC-authenticated; the rumor's pubkey field is untrusted plaintext.
// Without this check a malicious sealer can forge any rumor.pubkey,
// which would let them impersonate a FROST participant.
if (seal.pubkey !== rumor.pubkey) {
return
}
if (rumor.kind < PROTOCOL_KIND_MIN || rumor.kind > PROTOCOL_KIND_MAX) {
return
}
addEvent(rumor as NostrEvent)
} catch {
// Silently skip events that fail to decrypt or parse
}
},
})
return subscription
}
export async function publish(relays: string[], event: NostrEvent): Promise<void> {
await pool.publish(relays, event)
}
+477
View File
@@ -0,0 +1,477 @@
import { schnorr } from "@noble/curves/secp256k1.js"
import { bytesToHex, hexToBytes, concatBytes, numberToBytesBE, bytesToNumberBE } from "@noble/curves/utils.js"
import { sha256 } from "@noble/hashes/sha2.js"
import { makeEvent, prep, getHash, type HashedEvent } from "@welshman/util"
import { sort, sortBy } from "@welshman/lib"
// ─── Constants ───────────────────────────────────────────────────────────────
const G = schnorr.Point.BASE
const ZERO = schnorr.Point.ZERO
const Q = schnorr.Point.Fn.ORDER
// ─── Types ───────────────────────────────────────────────────────────────────
export type Hex = string
export type { HashedEvent }
export type QuorumMember = { pubkey: Hex; index: number }
/** Per-member quorum state — shard must be encrypted at rest */
export type QuorumState = {
quorumPubkey: Hex // Y as 32-byte x-only hex
shard: bigint // xⱼ — secret key share, never serialised plaintext
verificationShare: Hex // Yⱼ as compressed 33-byte hex
members: QuorumMember[]
threshold: number
commitments: Record<number, Hex[]> // participantIndex → Feldman commitments from last DKG/resharing
}
/** Kept between Round 1 and finalization; discard immediately after */
export type DkgRound1State = {
polynomial: bigint[]
commitments: Hex[]
}
/** Must never be persisted or reused across sessions */
export type SigningNonces = { d: bigint; e: bigint; D: Hex; E: Hex }
// ─── Scalar arithmetic ───────────────────────────────────────────────────────
const mod = (a: bigint, n = Q): bigint => ((a % n) + n) % n
function modPow(b: bigint, e: bigint, n: bigint): bigint {
let r = 1n
b = mod(b, n)
for (; e > 0n; e >>= 1n, b = b * b % n) {
if ((e & 1n) !== 0n) { r = r * b % n }
}
return r
}
const modInv = (a: bigint, n = Q): bigint => modPow(mod(a, n), n - 2n, n)
// ─── EC point helpers ────────────────────────────────────────────────────────
type Point = typeof G
const pt = (hex: Hex): Point => schnorr.Point.fromHex(hex)
const isOddY = (p: Point): boolean => p.y % 2n !== 0n
const xOnlyHex = (p: Point): Hex => bytesToHex(numberToBytesBE(p.x, 32))
const toHex = (n: bigint): Hex => bytesToHex(numberToBytesBE(mod(n), 32))
const fromHex = (h: Hex): bigint => bytesToNumberBE(hexToBytes(h))
// ─── Hashing ─────────────────────────────────────────────────────────────────
const enc = new TextEncoder()
/** Domain-separated SHA-256 */
function domainHash(tag: string, ...parts: Uint8Array[]): Uint8Array {
return sha256(concatBytes(enc.encode(tag + ":"), ...parts))
}
/** BIP-340 tagged hash */
function taggedHash(tag: string, ...parts: Uint8Array[]): Uint8Array {
const th = sha256(enc.encode(tag))
return sha256(concatBytes(th, th, ...parts))
}
function bip340Challenge(Rx: bigint, Y: Hex, msg: Uint8Array): bigint {
return mod(bytesToNumberBE(taggedHash(
"BIP0340/challenge",
numberToBytesBE(Rx, 32),
hexToBytes(Y),
msg,
)))
}
// ─── Polynomial / Feldman VSS ─────────────────────────────────────────────────
function samplePoly(degree: number): bigint[] {
return Array.from({ length: degree + 1 }, () =>
bytesToNumberBE(schnorr.utils.randomSecretKey()))
}
const evalPoly = (cs: bigint[], x: bigint): bigint =>
cs.reduceRight((acc, c) => mod(acc * x + c), 0n)
const feldmanCommitments = (poly: bigint[]): Hex[] =>
poly.map(a => G.multiply(a).toHex(true))
function verifyShare(s: bigint, j: number, commitments: Hex[]): boolean {
const lhs = G.multiply(mod(s))
const rhs = commitments.reduce((acc, c, k) =>
acc.add(pt(c).multiply(modPow(BigInt(j), BigInt(k), Q))), ZERO)
return lhs.equals(rhs)
}
function computeVerificationShare(index: number, allCommitments: Record<number, Hex[]>): Point {
return Object.values(allCommitments).reduce((acc, cs) =>
acc.add(cs.reduce((a, c, k) =>
a.add(pt(c).multiply(modPow(BigInt(index), BigInt(k), Q))), ZERO)), ZERO)
}
/** Lagrange coefficient for index i over contributing set S, evaluated at x=0 */
function lagrangeCoeff(S: number[], i: number): bigint {
return S.reduce((acc, j) =>
j === i ? acc : mod(acc * mod(BigInt(-j)) * modInv(mod(BigInt(i - j)))), 1n)
}
// ─── Schnorr PoK ─────────────────────────────────────────────────────────────
// Sigma protocol proving knowledge of a where C = a·G.
// Challenge binds to the session (eventId), the prover (pk), the nonce (R), and the commitment (C).
function provePoK(tag: string, eventId: Hex, pk: Hex, a: bigint, C: Hex): { R: Hex; s: Hex } {
const k = bytesToNumberBE(schnorr.utils.randomSecretKey())
const R = G.multiply(k).toHex(true)
const c = bytesToNumberBE(domainHash(tag, hexToBytes(eventId), hexToBytes(pk), hexToBytes(R), hexToBytes(C)))
return { R, s: toHex(mod(k + a * mod(c))) }
}
function verifyPoK(tag: string, eventId: Hex, pk: Hex, C: Hex, proof: { R: Hex; s: Hex }): boolean {
const c = bytesToNumberBE(domainHash(tag, hexToBytes(eventId), hexToBytes(pk), hexToBytes(proof.R), hexToBytes(C)))
return G.multiply(fromHex(proof.s)).equals(pt(proof.R).add(pt(C).multiply(mod(c))))
}
// ─── Transcript hash ──────────────────────────────────────────────────────────
function transcriptHash(eventId: Hex, commitments: Record<number, Hex[]>): Hex {
const sorted = sortBy(([k]) => Number(k), Object.entries(commitments))
return bytesToHex(sha256(concatBytes(
hexToBytes(eventId),
...sorted.flatMap(([, cs]) => cs.map(hexToBytes)),
)))
}
// ─── Participant indexing ─────────────────────────────────────────────────────
export function assignIndices(pubkeys: Hex[]): QuorumMember[] {
return sort(pubkeys).map((pubkey, i) => ({ pubkey, index: i + 1 }))
}
// ─── Quorum Creation (DKG) ────────────────────────────────────────────────────
export function createInvite(pubkey: Hex, members: Hex[], threshold: number, message = ""): HashedEvent {
return prep(makeEvent(7050, {
content: message,
tags: [["threshold", String(threshold)], ...members.map(m => ["member", m])],
}), pubkey)
}
export function dkgRound1(
pubkey: Hex,
inviteId: Hex,
threshold: number,
): { rumor: HashedEvent; state: DkgRound1State } {
const poly = samplePoly(threshold - 1)
const commitments = feldmanCommitments(poly)
const { R, s } = provePoK("frost/dkg/round1", inviteId, pubkey, poly[0], commitments[0])
return {
rumor: prep(makeEvent(7051, {
tags: [["e", inviteId], ...commitments.map(c => ["commit", c]), ["proof", R, s]],
}), pubkey),
state: { polynomial: poly, commitments },
}
}
export function verifyRound1(
inviteId: Hex,
senderPubkey: Hex,
commitments: Hex[],
proof: { R: Hex; s: Hex },
): boolean {
return verifyPoK("frost/dkg/round1", inviteId, senderPubkey, commitments[0], proof)
}
export function dkgRound2(pubkey: Hex, inviteId: Hex, poly: bigint[], recipientIndex: number): HashedEvent {
return prep(makeEvent(7052, {
tags: [["e", inviteId], ["share", toHex(evalPoly(poly, BigInt(recipientIndex)))]],
}), pubkey)
}
export function dkgFinalize(
inviteId: Hex,
ownIndex: number,
members: QuorumMember[],
allCommitments: Record<number, Hex[]>,
shares: Record<number, Hex>,
): { state: QuorumState; transcript: Hex } {
for (const [i, share] of Object.entries(shares)) {
if (!verifyShare(fromHex(share), ownIndex, allCommitments[Number(i)])) {
throw new Error(`Invalid share from participant ${i}`)
}
}
let shard = Object.values(shares).reduce((a, s) => mod(a + fromHex(s)), 0n)
let Y = Object.values(allCommitments).reduce((acc, cs) => acc.add(pt(cs[0])), ZERO)
let Yj = computeVerificationShare(ownIndex, allCommitments)
if (!G.multiply(shard).equals(Yj)) { throw new Error("Verification share mismatch") }
if (isOddY(Y)) { shard = mod(Q - shard); Yj = Yj.negate(); Y = Y.negate() }
const threshold = Object.values(allCommitments)[0].length
return {
state: { quorumPubkey: xOnlyHex(Y), shard, verificationShare: Yj.toHex(true), members, threshold, commitments: allCommitments },
transcript: transcriptHash(inviteId, allCommitments),
}
}
export function dkgConfirm(pubkey: Hex, inviteId: Hex, quorumPubkey: Hex, transcript: Hex): HashedEvent {
return prep(makeEvent(7053, {
tags: [["e", inviteId], ["quorum", quorumPubkey], ["transcript", transcript]],
}), pubkey)
}
// ─── Key Redistribution ───────────────────────────────────────────────────────
export function createResharingProposal(
pubkey: Hex,
quorumPubkey: Hex,
members: QuorumMember[],
contributors: Hex[],
newMembers: Hex[],
newThreshold: number,
commitments: Record<number, Hex[]>,
message = "",
): HashedEvent {
return prep(makeEvent(7054, {
content: message,
tags: [
["quorum", quorumPubkey],
["threshold", String(newThreshold)],
...contributors.map(c => ["contributor", c]),
...newMembers.map(m => ["member", m]),
...members.map(m => ["dkg_commit", m.pubkey, ...(commitments[m.index] ?? [])]),
],
}), pubkey)
}
export function resharingRound1(
pubkey: Hex,
proposalId: Hex,
quorumPubkey: Hex,
contributorSet: number[], // full set S — must be finalised before calling
ownIndex: number,
shard: bigint,
newThreshold: number,
): { rumor: HashedEvent; polynomial: bigint[] } {
const lambda = lagrangeCoeff(contributorSet, ownIndex)
const poly = samplePoly(newThreshold - 1)
poly[0] = mod(lambda * shard) // hᵢ(0) = λᵢ · xᵢ
const commitments = feldmanCommitments(poly)
const { R, s } = provePoK("frost/resharing/round1", proposalId, pubkey, poly[0], commitments[0])
return {
rumor: prep(makeEvent(7055, {
tags: [
["e", proposalId],
["quorum", quorumPubkey],
...commitments.map(c => ["commit", c]),
["proof", R, s],
],
}), pubkey),
polynomial: poly,
}
}
export function verifyResharingRound1(
proposalId: Hex,
contributorPubkey: Hex,
contributorIndex: number,
contributorSet: number[],
resharingCommitments: Hex[],
proof: { R: Hex; s: Hex },
originalCommitments: Record<number, Hex[]>,
): boolean {
if (!verifyPoK("frost/resharing/round1", proposalId, contributorPubkey, resharingCommitments[0], proof)) {
return false
}
const rawY = Object.values(originalCommitments).reduce((acc, cs) => acc.add(pt(cs[0])), ZERO)
const Ytilde = computeVerificationShare(contributorIndex, originalCommitments)
const Yi = isOddY(rawY) ? Ytilde.negate() : Ytilde
const lambda = lagrangeCoeff(contributorSet, contributorIndex)
return pt(resharingCommitments[0]).equals(Yi.multiply(mod(lambda)))
}
export function resharingRound2(
pubkey: Hex, proposalId: Hex, quorumPubkey: Hex, poly: bigint[], recipientIndex: number,
): HashedEvent {
return prep(makeEvent(7056, {
tags: [
["e", proposalId],
["quorum", quorumPubkey],
["share", toHex(evalPoly(poly, BigInt(recipientIndex)))],
],
}), pubkey)
}
export function resharingFinalize(
pubkey: Hex,
proposalId: Hex,
quorumPubkey: Hex,
ownIndex: number,
newMembers: Hex[],
newThreshold: number,
contributorCommitments: Record<number, Hex[]>,
shares: Record<number, Hex>,
): { rumor: HashedEvent; state: QuorumState; transcript: Hex } {
// Verify Σ Dᵢ[0] == Y (contributors are resharing real shards)
const sumD = Object.values(contributorCommitments).reduce((acc, cs) => acc.add(pt(cs[0])), ZERO)
if (xOnlyHex(isOddY(sumD) ? sumD.negate() : sumD) !== quorumPubkey) {
throw new Error("Contributor commitments do not sum to quorum pubkey")
}
for (const [i, share] of Object.entries(shares)) {
if (!verifyShare(fromHex(share), ownIndex, contributorCommitments[Number(i)])) {
throw new Error(`Invalid resharing share from contributor ${i}`)
}
}
const newShard = Object.values(shares).reduce((a, s) => mod(a + fromHex(s)), 0n)
const newYj = computeVerificationShare(ownIndex, contributorCommitments)
if (!G.multiply(newShard).equals(newYj)) { throw new Error("New verification share mismatch") }
const transcript = transcriptHash(proposalId, contributorCommitments)
return {
rumor: prep(makeEvent(7057, {
tags: [["e", proposalId], ["quorum", quorumPubkey], ["transcript", transcript]],
}), pubkey),
state: {
quorumPubkey, shard: newShard, verificationShare: newYj.toHex(true),
members: assignIndices(newMembers), threshold: newThreshold,
commitments: contributorCommitments,
},
transcript,
}
}
// ─── Collaborative Signing ────────────────────────────────────────────────────
export function createSignRequest(pubkey: Hex, quorumPubkey: Hex, unsignedEvent: object): HashedEvent {
return prep(makeEvent(7058, {
content: JSON.stringify(unsignedEvent),
tags: [["quorum", quorumPubkey]],
}), pubkey)
}
export function signingRound1(pubkey: Hex, requestId: Hex, quorumPubkey: Hex): { rumor: HashedEvent; nonces: SigningNonces } {
const d = bytesToNumberBE(schnorr.utils.randomSecretKey())
const e = bytesToNumberBE(schnorr.utils.randomSecretKey())
const D = G.multiply(d).toHex(true)
const E = G.multiply(e).toHex(true)
return {
rumor: prep(makeEvent(7059, {
tags: [["e", requestId], ["quorum", quorumPubkey], ["D", D], ["E", E]],
}), pubkey),
nonces: { d, e, D, E },
}
}
export function signingRound2(
pubkey: Hex,
requestId: Hex,
quorumPubkey: Hex,
msg: Uint8Array,
signingSet: number[],
ownIndex: number,
shard: bigint,
nonces: SigningNonces,
allNonces: Record<number, { D: Hex; E: Hex }>,
): { rumor: HashedEvent; z: bigint } {
const sorted = sortBy(x => x, signingSet)
const rho = bindingFactors(sorted, msg, allNonces)
let R = sorted.reduce((acc, i) =>
acc.add(pt(allNonces[i].D).add(pt(allNonces[i].E).multiply(rho[i]))), ZERO)
let d = nonces.d, e = nonces.e
if (isOddY(R)) { d = mod(Q - d); e = mod(Q - e); R = R.negate() }
const c = bip340Challenge(R.x, quorumPubkey, msg)
const lambda = lagrangeCoeff(sorted, ownIndex)
const z = mod(d + mod(e * rho[ownIndex]) + mod(lambda * mod(shard * c)))
return {
rumor: prep(makeEvent(7060, {
tags: [["e", requestId], ["quorum", quorumPubkey], ["z", toHex(z)], ...sorted.map(i => ["signer", String(i)])],
}), pubkey),
z,
}
}
export function aggregateSignature(
quorumPubkey: Hex,
msg: Uint8Array,
signingSet: number[],
allNonces: Record<number, { D: Hex; E: Hex }>,
shares: Record<number, bigint>,
commitments: Record<number, Hex[]>,
): Hex {
const sorted = sortBy(x => x, signingSet)
const rho = bindingFactors(sorted, msg, allNonces)
const Rtilde = sorted.reduce((acc, i) =>
acc.add(pt(allNonces[i].D).add(pt(allNonces[i].E).multiply(rho[i]))), ZERO)
const sigmaR = isOddY(Rtilde)
let R = sigmaR ? Rtilde.negate() : Rtilde
const c = bip340Challenge(R.x, quorumPubkey, msg)
const rawY = Object.values(commitments).reduce((acc, cs) => acc.add(pt(cs[0])), ZERO)
const sigmaY = isOddY(rawY)
for (const [iStr, zi] of Object.entries(shares)) {
const i = Number(iStr)
const Di = pt(allNonces[i].D)
const Ei = pt(allNonces[i].E)
const lambda = lagrangeCoeff(sorted, i)
const Ytilde = computeVerificationShare(i, commitments)
const Yi = sigmaY ? Ytilde.negate() : Ytilde
const base = Di.add(Ei.multiply(rho[i]))
const rhs = (sigmaR ? base.negate() : base).add(Yi.multiply(mod(lambda * c)))
if (!G.multiply(mod(zi)).equals(rhs)) {
throw new Error(`Invalid signature share from signer ${i}`)
}
}
const z = Object.values(shares).reduce((a, zi) => mod(a + zi), 0n)
const sigBytes = concatBytes(numberToBytesBE(R.x, 32), numberToBytesBE(z, 32))
if (!schnorr.verify(sigBytes, msg, hexToBytes(quorumPubkey))) {
throw new Error("Aggregated signature failed BIP-340 verification")
}
return bytesToHex(sigBytes)
}
// ─── Decline ─────────────────────────────────────────────────────────────────
export function createDecline(pubkey: Hex, initiatingEventId: Hex, quorumPubkey?: Hex, reason = ""): HashedEvent {
const tags: string[][] = [["e", initiatingEventId]]
if (quorumPubkey) { tags.push(["quorum", quorumPubkey]) }
return prep(makeEvent(7061, { content: reason, tags }), pubkey)
}
// ─── Internal helpers ─────────────────────────────────────────────────────────
export { getHash }
function bindingFactors(
sorted: number[],
msg: Uint8Array,
allNonces: Record<number, { D: Hex; E: Hex }>,
): Record<number, bigint> {
const nonceParts = sorted.flatMap(j => [hexToBytes(allNonces[j].D), hexToBytes(allNonces[j].E)])
return Object.fromEntries(sorted.map(i => [
i,
mod(bytesToNumberBE(domainHash(
"frost/sign/rho",
numberToBytesBE(BigInt(i), 4),
msg,
...nonceParts,
))),
]))
}
+183
View File
@@ -0,0 +1,183 @@
import { createStore, produce } from "solid-js/store"
import type { ISigner } from "applesauce-signers"
import { getJson, setJson } from "@welshman/lib"
import type { Hex, QuorumMember } from "./protocol"
// ── Model types ───────────────────────────────────────────────────────────────
/** A known quorum — no secret material */
export type QuorumRecord = {
quorumPubkey: Hex
members: QuorumMember[]
threshold: number
/** Feldman commitments per participant index, from the last completed DKG or resharing */
commitments: Record<number, Hex[]>
/** Ordered list of kind 7057 inner event IDs (rotation history) */
rotationRecords: Hex[]
}
/** The user's secret share for one quorum, with the shard encrypted at rest */
export type ShardRecord = {
quorumPubkey: Hex
index: number
verificationShare: Hex
/** NIP-44 self-encrypted hex string of the shard bigint */
encryptedShard: string
}
/** Proof-of-knowledge from a round-1 message */
export type PoKProof = { R: Hex; s: Hex }
/** Round-1 broadcast from one participant */
export type Round1Data = { commitments: Hex[]; proof: PoKProof }
/** Phase of a DKG session */
export type DkgPhase = "round1" | "round2" | "confirming" | "complete"
/** In-progress DKG session, keyed by the kind 7050 inner event ID */
export type DkgSession = {
inviteId: Hex
members: Hex[]
threshold: number
phase: DkgPhase
/** Own Feldman commitments (set once we broadcast round 1) */
myCommitments?: Hex[]
/** Own polynomial coefficients, NIP-44 self-encrypted */
myEncryptedPoly?: string
/** Round-1 data received, keyed by sender pubkey */
round1: Record<Hex, Round1Data>
/** Round-2 shares received, keyed by sender participant index */
round2: Record<number, Hex>
/** Members who declined, keyed by pubkey, value is their optional reason */
declines: Record<Hex, string>
}
/** Phase of a resharing session */
export type ResharingPhase = "round1" | "round2" | "confirming" | "complete"
/** In-progress resharing session, keyed by the kind 7054 inner event ID */
export type ResharingSession = {
proposalId: Hex
quorumPubkey: Hex
contributors: Hex[]
newMembers: Hex[]
newThreshold: number
phase: ResharingPhase
myCommitments?: Hex[]
myEncryptedPoly?: string
/** Round-1 data received, keyed by sender pubkey */
round1: Record<Hex, Round1Data>
/** Round-2 shares received, keyed by sender participant index */
round2: Record<number, Hex>
/** Members who declined, keyed by pubkey, value is their optional reason */
declines: Record<Hex, string>
}
/** Phase of a signing session */
export type SigningPhase = "round1" | "round2" | "complete"
/** In-progress signing session, keyed by the kind 7058 inner event ID */
export type SigningSession = {
requestId: Hex
quorumPubkey: Hex
/** Hex-encoded message bytes */
msgHex: Hex
phase: SigningPhase
signingSet: number[]
/** Own nonce commitments (present until we have broadcast our round-2 share) */
myNonces?: { D: Hex; E: Hex }
/** Round-1 nonce commitments from others, keyed by sender pubkey */
round1: Record<Hex, { D: Hex; E: Hex }>
/** Round-2 signature shares, keyed by participant index */
shares: Record<number, Hex>
/** Members who declined, keyed by pubkey, value is their optional reason */
declines: Record<Hex, string>
}
// ── Generic reactive store backed by localStorage ─────────────────────────────
type RecordStore<T> = Record<string, T>
type StoreActions<T> = {
get(id: string): T | undefined
upsert(id: string, value: T): void
patch(id: string, partial: Partial<T>): void
remove(id: string): void
values(): T[]
}
function makeLocalStore<T>(lsKey: string): [RecordStore<T>, StoreActions<T>] {
const [store, setStore] = createStore<RecordStore<T>>(getJson(lsKey) ?? {})
const persist = () => setJson(lsKey, store)
return [store, {
get: (id) => store[id],
upsert(id, value) {
setStore(produce(s => { s[id] = value }))
persist()
},
patch(id, partial) {
setStore(produce(s => {
if (s[id]) { Object.assign(s[id] as object, partial) }
}))
persist()
},
remove(id) {
setStore(produce(s => { delete s[id] }))
persist()
},
values: () => Object.values(store),
}]
}
// ── Per-model stores ──────────────────────────────────────────────────────────
export const [quora, quorumStore] = makeLocalStore<QuorumRecord>("nq:quora")
export const [shards, shardStore] = makeLocalStore<ShardRecord>("nq:shards")
export const [dkgSessions, dkgStore] = makeLocalStore<DkgSession>("nq:dkg")
export const [resharingSessions, resharingStore] = makeLocalStore<ResharingSession>("nq:resharing")
export const [signingSessions, signingStore] = makeLocalStore<SigningSession>("nq:signing")
// ── Shard encryption helpers ──────────────────────────────────────────────────
// Shards and polynomials are self-encrypted: NIP-44 encrypt(myPubkey, ...).
// The signer derives the conversation key from (myPrivkey, myPubkey), which is
// unique to the key and opaque to any other party.
export async function encryptShard(
shard: bigint,
myPubkey: Hex,
signer: ISigner,
): Promise<string> {
if (!signer.nip44) { throw new Error("Signer does not support NIP-44") }
return signer.nip44.encrypt(myPubkey, shard.toString(16))
}
export async function decryptShard(
encryptedShard: string,
myPubkey: Hex,
signer: ISigner,
): Promise<bigint> {
if (!signer.nip44) { throw new Error("Signer does not support NIP-44") }
const hex = await signer.nip44.decrypt(myPubkey, encryptedShard)
return BigInt("0x" + hex)
}
export async function encryptPoly(
poly: bigint[],
myPubkey: Hex,
signer: ISigner,
): Promise<string> {
if (!signer.nip44) { throw new Error("Signer does not support NIP-44") }
return signer.nip44.encrypt(myPubkey, JSON.stringify(poly.map(c => c.toString(16))))
}
export async function decryptPoly(
encryptedPoly: string,
myPubkey: Hex,
signer: ISigner,
): Promise<bigint[]> {
if (!signer.nip44) { throw new Error("Signer does not support NIP-44") }
const json = await signer.nip44.decrypt(myPubkey, encryptedPoly)
return (JSON.parse(json) as string[]).map(h => BigInt("0x" + h))
}
+80
View File
@@ -0,0 +1,80 @@
import { createSignal, createEffect } from "solid-js"
import { AccountManager } from "applesauce-accounts"
import { registerCommonAccountTypes } from "applesauce-accounts/accounts/common"
import type { IAccount } from "applesauce-accounts"
import { parseJson } from "@welshman/lib"
import { toast } from "solid-toast"
import { subscribeInbox, INBOX_RELAYS } from "./nostr"
// Shape of the object returned by Observable.subscribe()
type Subscription = { unsubscribe(): void }
export type View = "inbox" | { type: "quorum"; id: string }
const ACCOUNTS_KEY = "nq:accounts"
const ACTIVE_KEY = "nq:active"
export const manager = new AccountManager()
registerCommonAccountTypes(manager)
// Restore persisted accounts
const savedAccounts = parseJson(localStorage.getItem(ACCOUNTS_KEY) ?? "")
if (Array.isArray(savedAccounts)) {
try { manager.fromJSON(savedAccounts) } catch {}
}
// Restore active account
const savedActiveId = localStorage.getItem(ACTIVE_KEY)
if (savedActiveId && manager.getAccount(savedActiveId)) {
manager.setActive(savedActiveId)
}
// SolidJS signal mirrors manager.active$
export const [account, _setAccount] = createSignal<IAccount | null>(manager.active ?? null)
manager.active$.subscribe(acc => {
_setAccount(acc ?? null)
if (acc) {
localStorage.setItem(ACTIVE_KEY, acc.id)
} else {
localStorage.removeItem(ACTIVE_KEY)
}
})
manager.accounts$.subscribe(() => {
localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(manager.toJSON()))
})
export function login(acc: IAccount) {
manager.addAccount(acc)
manager.setActive(acc)
}
export function logout() {
manager.clearActive()
}
export const [view, setView] = createSignal<View>("inbox")
// Inbox subscription — one active subscription at a time
let inboxSub: Subscription | null = null
createEffect(() => {
const acc = account()
// Tear down any existing subscription first
if (inboxSub) {
inboxSub.unsubscribe()
inboxSub = null
}
if (!acc) {
return
}
try {
inboxSub = subscribeInbox(INBOX_RELAYS, acc.pubkey, acc)
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to subscribe to inbox")
}
})
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />