Add protocol impl and app scaffold
This commit is contained in:
+2
-1
@@ -1,2 +1,3 @@
|
||||
node_modules
|
||||
.todo
|
||||
dist
|
||||
.local
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
The user logs in via NIP-07, NIP-46, or private key. See https://gitea.coracle.social/coracle/caravel/raw/branch/master/frontend/src/views/Login.tsx for an example login component.
|
||||
|
||||
The app has a sidebar with an "inbox", a list of existing quorums, and a "create quorum" button. The bottom shows the user's profile name/nip05 and a logout button. On mobile, an icon opens a quorum list in a drawer, and a plus button opens the quorum creation form.
|
||||
|
||||
The inbox shows all events pending signature across all quorums, including events the user has not yet signed. Each event shows x/y signed. Events not yet signed by the user have options for signing and declining, each with an optional message posted to the NIP-17 quorum chat. The inbox also lists quorum invitations with options to join or decline, each with a message.
|
||||
|
||||
Create quorum opens a modal for selecting quorum members and threshold, the quorum's outbox relays, and the quorum's kind 0 profile information.
|
||||
|
||||
Clicking a quorum opens the quorum detail page, which shows the profile information for the quorum's pubkey, a list of members, a list of recent events (including unsigned/declined) published by the quorum, and a NIP-17 encrypted group chat among members under their own pubkeys. The overflow menu has two options: rotate membership and sign event.
|
||||
|
||||
Rotating membership is a special case of signing an event. The option opens a form with the member list (editable, including removing oneself) and the threshold. An optional message is posted to the NIP-17 quorum chat.
|
||||
|
||||
The sign event button opens a modal/drawer for choosing an event kind: public note (kind 1), relay selections (kind 10002), or profile (kind 0).
|
||||
|
||||
Use SolidJS, Tailwind, Preline, and applesauce for Nostr functionality. Use `@noble/curves` for FROST cryptographic primitives (DKG, Feldman VSS, threshold signing) and `@noble/hashes` for hash functions. Use toast for error messages, not inline errors.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jon Staab
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+42
-12
@@ -22,7 +22,7 @@ Each protocol flow is initiated by a single event (the invite, resharing proposa
|
||||
|
||||
## Event Kinds
|
||||
|
||||
All events in this protocol are sent as the `content` payload of a NIP-59 gift wrap using a kind 7049 wrapper instead of a kind 1059 with at least 16 bits of work on the wrapper per NIP 13. The `content` is encrypted to the recipient's pubkey and the wrap is sent to the recipients inbox relays (kind 10050) per NIP 17.
|
||||
All events in this protocol are sent as the `content` payload of a NIP-59 gift wrap (kind 1059) with at least 16 bits of proof-of-work per NIP-13. Each wrapper carries a `["t", "b7ed"]` tag identifying this sub-protocol, allowing recipients to filter their inbox efficiently. The `content` is encrypted to the recipient's pubkey and the wrap is sent to the recipient's inbox relays (kind 10050) per NIP-17.
|
||||
|
||||
## Quorum Creation
|
||||
|
||||
@@ -56,7 +56,7 @@ Each accepting participant Pᵢ:
|
||||
2. Computes Feldman commitments: `Cᵢ = [aᵢ₀·G, aᵢ₁·G, …, aᵢ,ₜ₋₁·G]` (compressed 33-byte EC points as hex)
|
||||
3. Computes a Schnorr proof of knowledge of `aᵢ₀`:
|
||||
- Sample ephemeral scalar `k`; compute `R = k·G`
|
||||
- `c = H("frost/dkg/round1" || session_id || pkᵢ || Cᵢ[0])`
|
||||
- `c = H("frost/dkg/round1" || session_id || pkᵢ || R || Cᵢ[0])`
|
||||
- `s = k + aᵢ₀ · c (mod q)`
|
||||
4. Sends to all other members (n−1 gift wraps, identical payload):
|
||||
|
||||
@@ -112,7 +112,7 @@ Each Pⱼ, after receiving shares from all other participants:
|
||||
|
||||
6. Stores `(xⱼ, Y, Yⱼ, members, threshold, Round-1 commitments)` durably. The Round-1 commitments are retained because they are needed to verify resharing participants' shards in Protocol 2.
|
||||
|
||||
7. Publishes a DKG confirmation event (kind 7053).
|
||||
7. Sends a DKG confirmation event (kind 7053) to all other n−1 members.
|
||||
|
||||
### Confirmation (kind 7053)
|
||||
|
||||
@@ -130,7 +130,7 @@ Each Pⱼ, after receiving shares from all other participants:
|
||||
|
||||
`transcript` enables equivocation detection: a malicious participant may send different Round-1 commitments to different members. If any two confirmations reference the same initiating event but carry different `transcript` or `quorum` values, all participants must abort.
|
||||
|
||||
The quorum is considered live once `t` confirmations with matching `transcript_hash` and `quorum_pubkey` have been observed.
|
||||
The quorum is considered live once all `n` members have sent confirmations with matching `transcript` and `quorum` values, or until a timeout elapses and all respondents match. Any single conflicting confirmation must trigger an immediate abort, regardless of how many matching confirmations have already been received.
|
||||
|
||||
## Key Redistribution
|
||||
|
||||
@@ -154,7 +154,9 @@ The initiator (a current member) sends to all current and prospective new member
|
||||
["contributor", "<pk_1>"],
|
||||
["contributor", "<pk_n>"],
|
||||
["member", "<pk_1'>"],
|
||||
["member", "<pk_n'>"]
|
||||
["member", "<pk_n'>"],
|
||||
["dkg_commit", "<pk_1>", "<C₁₀ hex>", "<C₁₁ hex>", "…"],
|
||||
["dkg_commit", "<pk_n>", "<Cₙ₀ hex>", "<Cₙ₁ hex>", "…"]
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -163,6 +165,8 @@ The event's inner ID identifies the resharing session; all subsequent events in
|
||||
|
||||
`contributor` tags list the exact contributing set `S` — the old members who will reshare their shards. `member` tags list the new member set after rotation. A retained member appears in both. New member indices are derived by sorting `member` pubkeys lexicographically (1-based); `contributor` pubkeys retain their original indices from the prior DKG session, which are used for Lagrange coefficient computation. Because `S` is fixed in the proposal, Lagrange coefficients can be computed immediately without waiting for Round 1 responses.
|
||||
|
||||
`dkg_commit` tags carry the Feldman commitments from the most recent DKG or resharing session for **every current quorum member** (not just contributors). One tag per member: the first value is the member's pubkey, followed by their ordered commitments `[C₀, C₁, …, Cₜ₋₁]`. New-only members use these to verify each contributor's first commitment in Round 1.
|
||||
|
||||
### Round 1 — Old Member Commitments (kind 7055)
|
||||
|
||||
Each contributing member Pᵢ (index `i` from the original DKG, shard `xᵢ`):
|
||||
@@ -195,7 +199,19 @@ Each contributing member Pᵢ (index `i` from the original DKG, shard `xᵢ`):
|
||||
}
|
||||
```
|
||||
|
||||
New members verify before proceeding: `Σᵢ∈S Dᵢ[0] == Y`. Abort if this check fails.
|
||||
New members verify before proceeding using the `dkg_commit` data from the proposal:
|
||||
|
||||
1. Determine the BIP-340 sign factor: let `σ_Y = −1` iff `Σⱼ Cⱼ₀` (the raw group key from the `dkg_commit` entries) has odd y-coordinate; otherwise `σ_Y = 1`.
|
||||
|
||||
2. For each contributor Pᵢ in S, compute Pᵢ's raw verification share from the original DKG:
|
||||
|
||||
`Ỹᵢ = Σⱼ Σₖ₌₀ᵗ⁻¹ ( i^k · Cⱼₖ )` (summing over **all** original members' `dkg_commit` entries)
|
||||
|
||||
Apply the same normalization used during DKG finalization: `Yᵢ = σ_Y · Ỹᵢ`.
|
||||
|
||||
3. Verify `Dᵢ[0] == λᵢ · Yᵢ` for each contributor. Abort if any check fails.
|
||||
|
||||
If all per-contributor checks pass, `Σᵢ∈S Dᵢ[0] == Y` holds automatically (Lagrange identity).
|
||||
|
||||
### Round 2 — Share Distribution (kind 7056)
|
||||
|
||||
@@ -231,7 +247,7 @@ Each new member Qⱼ, after receiving shares from all members of `S`:
|
||||
|
||||
5. Replaces stored quorum state with `(x'ⱼ, Y, Y'ⱼ, new_members, new_threshold, Round-1 commitments from this session)`.
|
||||
|
||||
6. Publishes resharing confirmation (kind 7057).
|
||||
6. Sends resharing confirmation (kind 7057) to all other n'−1 new members.
|
||||
|
||||
### Resharing Confirmation (kind 7057)
|
||||
|
||||
@@ -249,7 +265,7 @@ Each new member Qⱼ, after receiving shares from all members of `S`:
|
||||
|
||||
`transcript` enables equivocation detection for the resharing round, the same way it does for DKG confirmations. If any two kind 7057 events reference the same initiating event but carry different `transcript` or `quorum` values, all participants must abort.
|
||||
|
||||
The rotation is considered complete once `t'` new members have published kind 7057 confirmations with matching `transcript` and `quorum` values. Clients retain all kind 7057 confirmation sets in order to determine which members were active at a given time, which gates NIP-17 chat message display.
|
||||
The rotation is considered complete once all `n'` new members have sent kind 7057 confirmations with matching `transcript` and `quorum` values, or until a timeout elapses and all respondents match. Any single conflicting confirmation must trigger an immediate abort. Clients retain all kind 7057 confirmation sets in order to determine which members were active at a given time, which gates NIP-17 chat message display.
|
||||
|
||||
## Collaborative Signing
|
||||
|
||||
@@ -271,6 +287,8 @@ The initiator sends to all members. Any member who will not sign should respond
|
||||
|
||||
The event's inner ID identifies the signing session; all subsequent events in this flow reference it via `["e", "<id>"]`.
|
||||
|
||||
`msg` in all subsequent computations is the 32-byte NIP-01 event id of the unsigned event: the SHA-256 hash of its canonical NIP-01 serialisation `[0,pubkey,created_at,kind,tags,content]`.
|
||||
|
||||
### Round 1 — Nonce Commitments (kind 7059)
|
||||
|
||||
Each willing signer Pᵢ:
|
||||
@@ -294,9 +312,11 @@ Each willing signer Pᵢ:
|
||||
|
||||
### Round 2 — Signature Shares (kind 7060)
|
||||
|
||||
After collecting Round 1 from at least `t` signers, each Pᵢ:
|
||||
**Signing set finalisation**: After collecting at least `t` Round-1 nonces, the coordinator constructs the signing set `S` — the indices of exactly those participants whose nonces were received, ordered ascending — and distributes the complete finalized nonce list `{(j, Dⱼ, Eⱼ)}ⱼ∈S` to all Round-2 participants. Signers must not begin Round 2 before receiving this distribution.
|
||||
|
||||
1. Finalizes the signing set `S` (the participants whose commitments were collected)
|
||||
After receiving the finalized `S` and nonce list from the coordinator, each Pᵢ:
|
||||
|
||||
1. Uses the signing set `S` and nonce list as distributed by the coordinator
|
||||
2. Computes binding factors: `ρᵢ = H("frost/sign/rho" || i || msg || {(Dⱼ, Eⱼ)}ⱼ∈S)` for each `i ∈ S`
|
||||
3. Computes group nonce: `R = Σᵢ∈S (Dᵢ + ρᵢ·Eᵢ)`; if `R` has odd y-coordinate, negate `dᵢ, eᵢ` and use the even-y `R`
|
||||
4. Computes BIP-340 challenge: `c = H_BIP340("BIP0340/challenge" || R.x || Y || msg)`
|
||||
@@ -311,14 +331,24 @@ After collecting Round 1 from at least `t` signers, each Pᵢ:
|
||||
"tags": [
|
||||
["e", "<kind 7058 inner id>"],
|
||||
["quorum", "<Y x-only hex>"],
|
||||
["z", "<zᵢ hex scalar>"]
|
||||
["z", "<zᵢ hex scalar>"],
|
||||
["signer", "<index_1>"],
|
||||
["signer", "<index_n>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `signer` tags list every member of `S` by index. The coordinator rejects aggregation and aborts the session if any two received Round-2 events carry different signer sets.
|
||||
|
||||
### Aggregation
|
||||
|
||||
The coordinator (any member) aggregates:
|
||||
The coordinator first verifies each received share individually. Let `σ_Y = −1` iff the raw group key `Σⱼ Cⱼ₀` from stored DKG commitments has odd y-coordinate; otherwise `σ_Y = 1`. Let `σ_R = −1` iff the pre-normalization group nonce `R̃ = Σᵢ∈S (Dᵢ + ρᵢ·Eᵢ)` had odd y-coordinate; otherwise `σ_R = 1`. For each Pᵢ in S, compute `Ỹᵢ = Σⱼ Σₖ ( i^k · Cⱼₖ )` from stored DKG commitments, set `Yᵢ = σ_Y · Ỹᵢ`, and check:
|
||||
|
||||
`zᵢ·G == σ_R · (Dᵢ + ρᵢ·Eᵢ) + λᵢ · c · Yᵢ`
|
||||
|
||||
If any share fails, abort the session — the failing signer is identified and may be excluded from future attempts.
|
||||
|
||||
The coordinator then aggregates:
|
||||
|
||||
- `z = Σᵢ∈S zᵢ (mod q)`
|
||||
- Final signature: `(R.x, z)`
|
||||
|
||||
@@ -1,27 +1,7 @@
|
||||
# nq
|
||||
|
||||
This is a web application which allows for creating, rotating, and signing under multisig quorums for nostr keys.
|
||||
|
||||
# Application
|
||||
|
||||
The user should be able to log in via nip 07, nip 46, or via private key. See https://gitea.coracle.social/coracle/caravel/raw/branch/master/frontend/src/views/Login.tsx for an example login component.
|
||||
|
||||
From here, users should see a sidebar with an "inbox", a list of exiting quorums, and a "create quorum" button. At the bottom, show their profile and name/nip05 and a logout button. On mobile, show an icon which opens a quorum list to select from in a drawer, and a plus button which opens the quorum creation form.
|
||||
|
||||
The inbox view should show all events pending signature across all quorums, including events the user has not signed with his shard yet. Each event should show x/y signed, and events not signed by the user should show options for signing and declining signature. In either case, an optional message can be included which is posted to the nip 17 quorum chat. The inbox should also show a list of quorums the user has been invited to including options to join or decline membership, each with a message.
|
||||
|
||||
Create quorum opens a modal which allows the user to select the quorum members and threshold, the quorum's outbox relays, and set the quorum's kind 0 profile information.
|
||||
|
||||
When clicking on a quorum, open the quorum detail page. This should show the profile information for the quorum's pubkey, a list of quorum members, a list of recent events (including unsigned/declined) publshed by the quorum, and a quorum chat (a nip 17 encrypted chat including the members of the quorum under their own pubkeys). The overflow menu should include the following options: rotate membership, sign event.
|
||||
|
||||
Rotating membership is a special case of signing an event; this option opens a form showing the member list and the ability to edit it (including removing oneself) and the threshold. An optional message can be included which is posted to the nip 17 quorum chat.
|
||||
|
||||
The sign event button opens a modal/drawer which asks the user to choose which event kind: public note (kind 1), relay selections (kind 10002), profile (kind 0).
|
||||
|
||||
Use solidjs/tailwind/preline and applesauce for nostr functionality. Use `@noble/curves` for FROST cryptographic primitives (DKG, Feldman VSS, threshold signing) and `@noble/hashes` for hash functions. Use toast for error messages, not inline errors.
|
||||
|
||||
# Protocol
|
||||
|
||||
See [PROTOCOL.md](PROTOCOL.md) for the full protocol specification.
|
||||
|
||||
# Other details
|
||||
|
||||
The NIP 17 quorum chat should include messages from members only during times when they were members of the quorum, based on stored quorum rotation events. These events should be stored durably in indexeddb.
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Quorum</title>
|
||||
<title>NQ</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+3
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "quorum",
|
||||
"name": "nq",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -11,6 +11,8 @@
|
||||
"dependencies": {
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@welshman/lib": "^0.8.16",
|
||||
"@welshman/util": "^0.8.16",
|
||||
"applesauce-accounts": "^6.0.0",
|
||||
"applesauce-actions": "^6.1.0",
|
||||
"applesauce-core": "^6.1.0",
|
||||
|
||||
Generated
+79
-11
@@ -14,6 +14,12 @@ importers:
|
||||
'@noble/hashes':
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0
|
||||
'@welshman/lib':
|
||||
specifier: ^0.8.16
|
||||
version: 0.8.16
|
||||
'@welshman/util':
|
||||
specifier: ^0.8.16
|
||||
version: 0.8.16(@noble/curves@2.2.0)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3))
|
||||
applesauce-accounts:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0(@capacitor/core@7.6.6)(typescript@6.0.3)
|
||||
@@ -44,7 +50,7 @@ importers:
|
||||
devDependencies:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.3.0
|
||||
version: 4.3.0(vite@8.0.16(jiti@2.7.0))
|
||||
version: 4.3.0(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0))
|
||||
autoprefixer:
|
||||
specifier: ^10.5.0
|
||||
version: 10.5.0(postcss@8.5.15)
|
||||
@@ -62,10 +68,10 @@ importers:
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^8.0.16
|
||||
version: 8.0.16(jiti@2.7.0)
|
||||
version: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)
|
||||
vite-plugin-solid:
|
||||
specifier: ^2.11.12
|
||||
version: 2.11.12(solid-js@1.9.13)(vite@8.0.16(jiti@2.7.0))
|
||||
version: 2.11.12(solid-js@1.9.13)(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -513,6 +519,9 @@ packages:
|
||||
'@types/debug@4.1.13':
|
||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||
|
||||
'@types/events@3.0.3':
|
||||
resolution: {integrity: sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==}
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||
|
||||
@@ -522,9 +531,26 @@ packages:
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/node@25.9.2':
|
||||
resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==}
|
||||
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@welshman/lib@0.8.16':
|
||||
resolution: {integrity: sha512-g9r6NgGFaxQGvPRJkw6ff2aiBgWwqUSNCYXG0QezJh6DOYBE/QyXJwqxdea9d3y7e6YiBQ1zNRJWhgJAm073JQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
'@welshman/util@0.8.16':
|
||||
resolution: {integrity: sha512-7c7lIWFgVKw+qaC9Gcbt21cwX9YrBZnDAVcCxjodlb4OgrbjEBsHorHevV+vQKWLO3dA5CisuD6E+0JjFX1F1w==}
|
||||
peerDependencies:
|
||||
'@noble/curves': ^1.9.7
|
||||
'@welshman/lib': 0.8.16
|
||||
nostr-tools: ^2.19.4
|
||||
|
||||
'@yr/monotone-cubic-spline@1.0.3':
|
||||
resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==}
|
||||
|
||||
@@ -662,6 +688,10 @@ packages:
|
||||
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
|
||||
extend@3.0.2:
|
||||
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
||||
|
||||
@@ -713,6 +743,9 @@ packages:
|
||||
jquery@4.0.0:
|
||||
resolution: {integrity: sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==}
|
||||
|
||||
js-base64@3.7.8:
|
||||
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -1040,6 +1073,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@7.24.6:
|
||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||
|
||||
unified@11.0.5:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
|
||||
@@ -1519,12 +1555,12 @@ snapshots:
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.0
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.3.0
|
||||
|
||||
'@tailwindcss/vite@4.3.0(vite@8.0.16(jiti@2.7.0))':
|
||||
'@tailwindcss/vite@4.3.0(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.3.0
|
||||
'@tailwindcss/oxide': 4.3.0
|
||||
tailwindcss: 4.3.0
|
||||
vite: 8.0.16(jiti@2.7.0)
|
||||
vite: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)
|
||||
|
||||
'@tybys/wasm-util@0.10.2':
|
||||
dependencies:
|
||||
@@ -1558,6 +1594,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
|
||||
'@types/events@3.0.3': {}
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -1568,8 +1606,31 @@ snapshots:
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/node@25.9.2':
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 25.9.2
|
||||
|
||||
'@welshman/lib@0.8.16':
|
||||
dependencies:
|
||||
'@scure/base': 1.2.6
|
||||
'@types/events': 3.0.3
|
||||
events: 3.3.0
|
||||
|
||||
'@welshman/util@0.8.16(@noble/curves@2.2.0)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@noble/curves': 2.2.0
|
||||
'@types/ws': 8.18.1
|
||||
'@welshman/lib': 0.8.16
|
||||
js-base64: 3.7.8
|
||||
nostr-tools: 2.23.5(typescript@6.0.3)
|
||||
nostr-wasm: 0.1.0
|
||||
|
||||
'@yr/monotone-cubic-spline@1.0.3': {}
|
||||
|
||||
apexcharts@4.7.0:
|
||||
@@ -1792,6 +1853,8 @@ snapshots:
|
||||
|
||||
escape-string-regexp@5.0.0: {}
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
extend@3.0.2: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
@@ -1821,6 +1884,8 @@ snapshots:
|
||||
|
||||
jquery@4.0.0: {}
|
||||
|
||||
js-base64@3.7.8: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
@@ -2247,6 +2312,8 @@ snapshots:
|
||||
|
||||
typescript@6.0.3: {}
|
||||
|
||||
undici-types@7.24.6: {}
|
||||
|
||||
unified@11.0.5:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -2294,7 +2361,7 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vite-plugin-solid@2.11.12(solid-js@1.9.13)(vite@8.0.16(jiti@2.7.0)):
|
||||
vite-plugin-solid@2.11.12(solid-js@1.9.13)(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@types/babel__core': 7.20.5
|
||||
@@ -2302,12 +2369,12 @@ snapshots:
|
||||
merge-anything: 5.1.7
|
||||
solid-js: 1.9.13
|
||||
solid-refresh: 0.6.3(solid-js@1.9.13)
|
||||
vite: 8.0.16(jiti@2.7.0)
|
||||
vitefu: 1.1.3(vite@8.0.16(jiti@2.7.0))
|
||||
vite: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)
|
||||
vitefu: 1.1.3(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite@8.0.16(jiti@2.7.0):
|
||||
vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
@@ -2315,12 +2382,13 @@ snapshots:
|
||||
rolldown: 1.0.3
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.2
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.7.0
|
||||
|
||||
vitefu@1.1.3(vite@8.0.16(jiti@2.7.0)):
|
||||
vitefu@1.1.3(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)):
|
||||
optionalDependencies:
|
||||
vite: 8.0.16(jiti@2.7.0)
|
||||
vite: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
|
||||
+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" />
|
||||
+2
-1
@@ -7,7 +7,8 @@
|
||||
"jsxImportSource": "solid-js",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user