Add protocol impl and app scaffold
This commit is contained in:
+10
-1
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
+2
-1
@@ -1,2 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "preline/plugin";
|
||||
@source "../node_modules/preline/dist/*.js";
|
||||
@import "../node_modules/preline/variants.css";
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user