4 Commits
Author SHA1 Message Date
reya 348504c33a . 2026-10-30 14:15:14 +07:00
reya 41837e487b add reveal text 2026-10-30 14:03:14 +07:00
reya 905a339e3e update ui 2026-10-30 12:51:05 +07:00
reya 6ce20927ee wip 2026-10-30 10:37:29 +07:00
34 changed files with 282 additions and 5330 deletions
+2 -3
View File
@@ -39,9 +39,8 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Rust nightly - name: Install Rust nightly
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@nightly
with: with:
toolchain: nightly-2026-10-31
targets: wasm32-unknown-unknown targets: wasm32-unknown-unknown
components: llvm-tools-preview components: llvm-tools-preview
@@ -52,4 +51,4 @@ jobs:
env: env:
CC_wasm32_unknown_unknown: clang CC_wasm32_unknown_unknown: clang
AR_wasm32_unknown_unknown: llvm-ar AR_wasm32_unknown_unknown: llvm-ar
run: cargo +nightly-2026-10-31 check -p coop_web --target wasm32-unknown-unknown run: cargo +nightly check -p coop_web --target wasm32-unknown-unknown
Generated
+8 -1002
View File
File diff suppressed because it is too large Load Diff
+1 -14
View File
@@ -34,7 +34,7 @@ nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-browser-signer = { git = "https://github.com/rust-nostr/nostr" } nostr-browser-signer = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr-sqlite = { git = "https://github.com/rust-nostr/nostr" } nostr-sqlite = { git = "https://github.com/rust-nostr/nostr" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip60", "nip98" ] } nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] }
# Crypto (NIP-17 encrypted file messages) # Crypto (NIP-17 encrypted file messages)
aes-gcm = "0.10" aes-gcm = "0.10"
@@ -48,13 +48,6 @@ hmac = "0.12"
# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it # Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it
rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng", "thread_rng" ] } rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng", "thread_rng" ] }
# Cashu (NIP-60). From git: the published 0.18.x line targets nostr 0.44 and
# cannot unify with the git rust-nostr used here, while main targets 0.45.x
# (redirected to this repo by the [patch.crates-io] section below). redb
# storage: pure Rust, avoids the libsqlite3-sys links clash with nostr-sqlite.
cdk = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
cdk-redb = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
# Others # Others
anyhow = "1.0.44" anyhow = "1.0.44"
tts = { git = "https://github.com/ndarilek/tts-rs" } tts = { git = "https://github.com/ndarilek/tts-rs" }
@@ -73,12 +66,6 @@ webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] } tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
instant = "0.1" instant = "0.1"
# cdk's registry nostr/nostr-sdk requirements are redirected onto the same
# git repo coop already uses, keeping a single copy of each in the graph.
[patch.crates-io]
nostr = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
[profile.release] [profile.release]
strip = true strip = true
opt-level = "z" opt-level = "z"
+1 -24
View File
@@ -947,11 +947,7 @@ async fn extract_rumor(
let Some(device_signer) = device_signer else { let Some(device_signer) = device_signer else {
return Err(error); return Err(error);
}; };
match try_unwrap_with(device_signer, gift_wrap).await { try_unwrap_with(device_signer, gift_wrap).await?
Ok(unwrapped) => unwrapped,
// The wrap may be sealed to a key retired by a rotation
Err(_) => try_unwrap_archived(client, signer, gift_wrap).await?,
}
} }
}; };
let mut rumor = unwrapped.rumor; let mut rumor = unwrapped.rumor;
@@ -1007,25 +1003,6 @@ async fn try_unwrap_with(
}) })
} }
/// Unwrap with an encryption key retired by a rotation; peers may still seal
/// to the previous key until their announcement cache refreshes
async fn try_unwrap_archived(
client: &Client,
signer: &UniversalSigner,
gift_wrap: &Event,
) -> Result<UnwrappedGift, Error> {
for keys in DeviceRegistry::archived_keys(client, signer).await? {
let archived = UniversalSigner::new(keys);
if let Ok(unwrapped) = try_unwrap_with(&archived, gift_wrap).await {
return Ok(unwrapped);
}
}
Err(anyhow!(
"No archived encryption key could unwrap the gift wrap"
))
}
/// Stores an unwrapped event in local database with reference to original /// Stores an unwrapped event in local database with reference to original
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> { async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
let room_id = rumor.uniq_id().to_string(); let room_id = rumor.uniq_id().to_string();
-24
View File
@@ -16,7 +16,6 @@ use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet."; const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key yet. Set one up in the settings to message them."; const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key yet. Set one up in the settings to message them.";
const KEY_STALE: &str = "Your encryption key is out of date; sync it from another device in the settings to keep messaging.";
/// The outcome of delivering an encrypted rumor to a single receiver. /// The outcome of delivering an encrypted rumor to a single receiver.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -554,7 +553,6 @@ impl Room {
let config = self.config.clone(); let config = self.config.clone();
let device = DeviceRegistry::global(cx); let device = DeviceRegistry::global(cx);
let resyncing = device.read(cx).is_resyncing();
let encryption_signer = device.read(cx).signer(cx); let encryption_signer = device.read(cx).signer(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
@@ -576,28 +574,6 @@ impl Room {
Some(cx.background_spawn(async move { Some(cx.background_spawn(async move {
let signer_kind = config.signer_kind(); let signer_kind = config.signer_kind();
// Block sends while the local encryption key is stale against the
// current announcement; the replacement is being synced from
// another device
if resyncing && matches!(signer_kind, SignerKind::Auto | SignerKind::Encryption) {
let mut reports: Vec<SendReport> = receivers
.into_iter()
.map(|receiver| SendReport {
receiver: receiver.public_key(),
result: Err(KEY_STALE.into()),
})
.collect();
if config.backup() {
reports.push(SendReport {
receiver: sender.public_key(),
result: Err(KEY_STALE.into()),
});
}
return reports;
}
// Deliver to every receiver concurrently // Deliver to every receiver concurrently
let sends = receivers.into_iter().map(|receiver| { let sends = receivers.into_iter().map(|receiver| {
deliver( deliver(
-3
View File
@@ -23,6 +23,3 @@ smallvec.workspace = true
serde.workspace = true serde.workspace = true
regex = "1" regex = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
nip60_ui = { path = "../nip60_ui" }
-47
View File
@@ -681,41 +681,6 @@ impl ChatPanel {
}); });
} }
/// Open the nutzap dialog for the room's peer; direct chats only
fn open_nutzap(&mut self, window: &mut Window, cx: &mut Context<Self>) {
#[cfg(target_arch = "wasm32")]
let _ = (window, cx);
#[cfg(not(target_arch = "wasm32"))]
{
let Some(room) = self.room.upgrade() else {
return;
};
let nostr = NostrRegistry::global(cx);
let Some(me) = nostr.read(cx).current_user() else {
return;
};
let peers: Vec<PublicKey> = room
.read(cx)
.members()
.iter()
.copied()
.filter(|key| *key != me)
.collect();
if let [recipient] = peers[..] {
nip60_ui::open_nutzap(window, cx, recipient, None);
} else {
window.push_notification(
Notification::warning("Nutzaps can only be sent in direct chats"),
cx,
);
}
}
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Get the user's configured blossom server // Get the user's configured blossom server
let server = AppSettings::get_file_server(cx); let server = AppSettings::get_file_server(cx);
@@ -2047,18 +2012,6 @@ impl Render for ChatPanel {
this.upload(window, cx); this.upload(window, cx);
})), })),
) )
.when(cfg!(not(target_arch = "wasm32")), |this| {
this.child(
Button::new("nutzap")
.icon(gpui_kit::assets::IconName::Coins)
.tooltip("Send eCash")
.disabled(dictating)
.ghost()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open_nutzap(window, cx);
})),
)
})
.when(dictating, |this| { .when(dictating, |this| {
this.child( this.child(
h_flex() h_flex()
+165 -488
View File
@@ -1,10 +1,9 @@
use std::cell::Cell; use std::cell::Cell;
use std::collections::{HashSet, VecDeque}; use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow}; use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::{ use gpui::{
@@ -17,14 +16,12 @@ use gpui_kit::component::notification::{Notification, NotificationType};
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::Directory; use person::Directory;
use settings::{AppSettings, NIP4E_ROTATION_MAX_DAYS, NIP4E_ROTATION_MIN_DAYS}; use settings::AppSettings;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{Announcement, NostrRegistry, UniversalSigner, credentials}; use state::{Announcement, NostrRegistry, UniversalSigner, credentials};
use ui::Avatar; use ui::Avatar;
mod storage; const IDENTIFIER: &str = "coop:device";
use storage::{SECONDS_PER_DAY, archive_key, get_keys, load_device_keys, set_keys};
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx); DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
@@ -62,18 +59,10 @@ impl DeviceEvent {
#[derive(Debug)] #[derive(Debug)]
pub struct DeviceRegistry { pub struct DeviceRegistry {
signer: Entity<Option<UniversalSigner>>, signer: Entity<Option<UniversalSigner>>,
/// Ephemeral client key for the in-flight key request; never persisted /// Whether there is a pending request for encryption key approval
app_keys: Option<Keys>, pub(crate) pending_request: bool,
/// The key request currently awaiting user approval on this device
pending_request: Option<PendingRequest>,
/// Handled key-sync request ids, bounded and persisted
processed_requests: VecDeque<EventId>,
/// Whether an announcement has been made for this device /// Whether an announcement has been made for this device
pub(crate) announcement_existed: Arc<AtomicBool>, pub(crate) announcement_existed: Arc<AtomicBool>,
/// Whether the local key is stale against the current announcement.
resync: Arc<AtomicBool>,
/// Whether a key rotation is in flight, to coalesce overlapping requests
rotating: bool,
/// Person directory for requesters shown in notifications /// Person directory for requesters shown in notifications
directory: Entity<Directory>, directory: Entity<Directory>,
/// Relay notification listener /// Relay notification listener
@@ -119,21 +108,6 @@ impl DeviceRegistry {
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) { if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
// The old account's state must not leak into the new one // The old account's state must not leak into the new one
this.announcement_existed.store(false, Ordering::Relaxed); this.announcement_existed.store(false, Ordering::Relaxed);
this.resync.store(false, Ordering::Relaxed);
// The in-flight request and client key belong to the old account
this.app_keys = None;
if this.pending_request.take().is_some() {
// The old approval must not target the new account's signer
if let Some(window) = cx.windows().first().copied() {
window
.update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
}
this.handle_notifications(cx); this.handle_notifications(cx);
this.get_announcement(cx); this.get_announcement(cx);
} }
@@ -141,19 +115,15 @@ impl DeviceRegistry {
); );
cx.defer(move |cx| { cx.defer(move |cx| {
let _ = entity.update(cx, |this, cx| { entity
this.handle_notifications(cx); .update(cx, |this, cx| this.handle_notifications(cx))
}); .ok();
}); });
Self { Self {
signer, signer,
app_keys: None, pending_request: false,
pending_request: None,
processed_requests: VecDeque::new(),
announcement_existed: Arc::new(AtomicBool::new(false)), announcement_existed: Arc::new(AtomicBool::new(false)),
resync: Arc::new(AtomicBool::new(false)),
rotating: false,
directory: Directory::new(cx), directory: Directory::new(cx),
notification_listener: None, notification_listener: None,
tasks: vec![], tasks: vec![],
@@ -171,8 +141,6 @@ impl DeviceRegistry {
let announcement_existed = self.announcement_existed.clone(); let announcement_existed = self.announcement_existed.clone();
let (tx, rx) = flume::bounded::<Event>(100); let (tx, rx) = flume::bounded::<Event>(100);
let read = credentials::read_processed_requests(cx);
self.notification_listener = Some(cx.background_spawn(async move { self.notification_listener = Some(cx.background_spawn(async move {
let mut notifications = client.notifications(); let mut notifications = client.notifications();
let mut processed_events = HashSet::new(); let mut processed_events = HashSet::new();
@@ -207,21 +175,6 @@ impl DeviceRegistry {
})); }));
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
// Hydrate the processed-request log before routing any events
match read.await {
Ok(bytes) => {
let processed = bytes
.map(|bytes| parse_processed_requests(&bytes))
.unwrap_or_default();
this.update(cx, |this, _cx| {
this.processed_requests = processed;
})
.ok();
}
Err(e) => log::error!("Failed to read processed key-sync requests: {e}"),
}
while let Ok(event) = rx.recv_async().await { while let Ok(event) = rx.recv_async().await {
match event.kind { match event.kind {
Kind::Custom(10044) => { Kind::Custom(10044) => {
@@ -238,7 +191,7 @@ impl DeviceRegistry {
// New response event from the master device // New response event from the master device
Kind::Custom(4455) => { Kind::Custom(4455) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.handle_transfer(event, cx); this.extract_encryption(event, cx);
})?; })?;
} }
_ => {} _ => {}
@@ -248,62 +201,12 @@ impl DeviceRegistry {
})); }));
} }
/// Record a handled key-sync request id, persisting a bounded log /// Set whether there is a pending request for encryption key approval
fn mark_request_processed(&mut self, event_id: EventId, cx: &mut Context<Self>) { fn set_pending_request(&mut self, pending: bool, cx: &mut Context<Self>) {
if self.processed_requests.contains(&event_id) { self.pending_request = pending;
return;
}
self.processed_requests.push_back(event_id);
while self.processed_requests.len() > PROCESSED_REQUESTS_LIMIT {
self.processed_requests.pop_front();
}
let bytes: Vec<u8> = self
.processed_requests
.iter()
.flat_map(|id| id.to_hex().into_bytes())
.collect();
let write = credentials::write_processed_requests(cx, &bytes);
cx.background_spawn(async move {
if let Err(e) = write.await {
log::error!("Failed to persist processed key-sync requests: {e}");
}
})
.detach();
}
/// Withdraw the pending approval.
fn resolve_pending_request(&mut self, cx: &mut Context<Self>) {
let Some(pending) = self.pending_request.take() else {
return;
};
self.mark_request_processed(pending.event.id, cx);
if let Some(window) = cx.windows().first().copied() {
window
.update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
cx.notify(); cx.notify();
} }
/// Route a key transfer.
fn handle_transfer(&mut self, event: Event, cx: &mut Context<Self>) {
let resolved = self
.pending_request
.as_ref()
.is_some_and(|pending| transfer_addresses(&event, pending.client_key));
if resolved {
self.resolve_pending_request(cx);
return;
}
self.extract_encryption(event, cx);
}
/// Get the signer /// Get the signer
pub fn signer(&self, cx: &App) -> Option<UniversalSigner> { pub fn signer(&self, cx: &App) -> Option<UniversalSigner> {
self.signer.read(cx).clone() self.signer.read(cx).clone()
@@ -315,16 +218,9 @@ impl DeviceRegistry {
*this = Some(UniversalSigner::new(new_signer)); *this = Some(UniversalSigner::new(new_signer));
cx.notify(); cx.notify();
}); });
// The local key is now consistent with the announcement
self.resync.store(false, Ordering::Release);
cx.emit(DeviceEvent::Set); cx.emit(DeviceEvent::Set);
} }
/// Whether the local encryption key is stale against the current announcement.
pub fn is_resyncing(&self) -> bool {
self.resync.load(Ordering::Acquire)
}
/// Backup the encryption's secret key, returning the `nsec1...` content /// Backup the encryption's secret key, returning the `nsec1...` content
pub fn backup_bytes(&self, cx: &App) -> Task<Result<String, Error>> { pub fn backup_bytes(&self, cx: &App) -> Task<Result<String, Error>> {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
@@ -427,88 +323,6 @@ impl DeviceRegistry {
})); }));
} }
/// Replace the encryption key with a new one, archiving the old key.
pub fn rotate(&mut self, cx: &mut Context<Self>) {
if self.rotating {
return;
}
self.rotating = true;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
self.tasks.push(cx.spawn(async move |this, cx| {
let result: Result<Keys, Error> = async {
// Archive the current key before replacing it
if let Ok(old) = get_keys(&client, &signer).await {
archive_key(&client, &signer, &old.secret_key().to_secret_hex()).await?;
}
let created = this
.update(cx, |this, cx| this.create_encryption(Keys::generate(), cx))?
.await?;
this.update(cx, |this, cx| {
this.set_signer(created.clone(), cx);
this.wait_for_request(cx);
})?;
Ok(created)
}
.await;
this.update(cx, |this, cx| {
this.rotating = false;
if let Err(e) = result {
cx.emit(DeviceEvent::error(e.to_string()));
}
})
.ok();
Ok(())
}));
}
/// Rotate the encryption key when the configured interval has elapsed.
///
/// Runs after the announcement reconciled, outside the blocking boot path.
pub fn rotate_if_due(&mut self, cx: &mut Context<Self>) {
let Some(interval_days) = AppSettings::get_nip4e_rotation_days(cx) else {
return;
};
let interval_days = interval_days.clamp(NIP4E_ROTATION_MIN_DAYS, NIP4E_ROTATION_MAX_DAYS);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
self.tasks.push(cx.spawn(async move |this, cx| {
let Ok(keys) = load_device_keys(&client, &signer).await else {
return Ok(());
};
let elapsed = Timestamp::now()
.as_secs()
.saturating_sub(keys.created_at.as_secs());
if elapsed < u64::from(interval_days) * SECONDS_PER_DAY {
return Ok(());
}
this.update(cx, |this, cx| this.rotate(cx)).ok();
Ok(())
}));
}
/// Load encryption keys retired by a rotation from the local database.
pub async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
storage::archived_keys(client, signer).await
}
/// Create new encryption key and announce it to user's relay list /// Create new encryption key and announce it to user's relay list
fn create_encryption(&self, keys: Keys, cx: &App) -> Task<Result<Keys, Error>> { fn create_encryption(&self, keys: Keys, cx: &App) -> Task<Result<Keys, Error>> {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
@@ -517,7 +331,6 @@ impl DeviceRegistry {
let secret = keys.secret_key().to_secret_hex(); let secret = keys.secret_key().to_secret_hex();
let n = keys.public_key(); let n = keys.public_key();
let created_at = Timestamp::now();
cx.background_spawn(async move { cx.background_spawn(async move {
// Construct an announcement event // Construct an announcement event
@@ -527,7 +340,7 @@ impl DeviceRegistry {
.await?; .await?;
// Persist the key locally before announcing it // Persist the key locally before announcing it
set_keys(&client, &signer, &secret, created_at).await?; set_keys(&client, &signer, &secret).await?;
// Publish announcement // Publish announcement
client client
@@ -570,12 +383,10 @@ impl DeviceRegistry {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_signer(keys, cx); this.set_signer(keys, cx);
this.wait_for_request(cx); this.wait_for_request(cx);
this.rotate_if_due(cx);
})?; })?;
} else { } else {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.resync.store(true, Ordering::Release); this.request(cx);
this.request(Some(device_pubkey), cx);
})?; })?;
} }
Ok(()) Ok(())
@@ -603,17 +414,15 @@ impl DeviceRegistry {
} }
/// Request encryption keys from other device /// Request encryption keys from other device
pub(crate) fn request( pub(crate) fn request(&mut self, cx: &mut Context<Self>) {
&mut self,
expected_encryption: Option<PublicKey>,
cx: &mut Context<Self>,
) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let app_keys = self.app_keys.get_or_insert_with(Keys::generate).clone();
let app_keys_task = get_or_init_app_keys(cx);
let task: Task<Result<Option<Event>, Error>> = cx.background_spawn(async move { let task: Task<Result<Option<Event>, Error>> = cx.background_spawn(async move {
let app_keys = app_keys_task.await?;
let app_pubkey = app_keys.public_key(); let app_pubkey = app_keys.public_key();
let public_key = signer.get_public_key_async().await?; let public_key = signer.get_public_key_async().await?;
@@ -625,7 +434,8 @@ impl DeviceRegistry {
.limit(1); .limit(1);
if let Some(event) = client.database().query(filter).await?.into_iter().next() { if let Some(event) = client.database().query(filter).await?.into_iter().next() {
// Only reuse the stored approval if its key is still valid // Only reuse the stored approval if its key is still valid,
// otherwise it's stale and a fresh request is needed
if decrypt_approval(&client, &app_keys, public_key, &event) if decrypt_approval(&client, &app_keys, public_key, &event)
.await .await
.is_ok() .is_ok()
@@ -634,26 +444,9 @@ impl DeviceRegistry {
} }
} }
// No usable approval event found, construct a request event. // No usable approval event found, construct a request event
//
// The `pubkey` tag is a compatibility alias for the draft's `P`,
// `relay` hints tell other devices where the response is accepted,
// `n` names the exact key this device expects to be transferred
let mut tags = vec![
Tag::custom("P", vec![app_pubkey]),
Tag::custom("pubkey", vec![app_pubkey]),
];
for url in write_relay_hints(&client, public_key).await? {
tags.push(Tag::custom("relay", vec![url.to_string()]));
}
if let Some(expected) = expected_encryption {
tags.push(Tag::custom("n", vec![expected]));
}
let event = EventBuilder::new(Kind::Custom(4454), "") let event = EventBuilder::new(Kind::Custom(4454), "")
.tags(tags) .tags(vec![Tag::custom("P", vec![app_pubkey])])
.finalize_async(&signer) .finalize_async(&signer)
.await?; .await?;
@@ -690,13 +483,19 @@ impl DeviceRegistry {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let app_keys_task = get_or_init_app_keys(cx);
let Some(app_keys) = self.app_keys.clone() else {
log::warn!("Cannot wait for approval without a client key");
return;
};
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let app_keys = match app_keys_task.await {
Ok(app_keys) => app_keys,
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::error(e.to_string()));
})?;
return Ok(());
}
};
this.update(cx, |_this, cx| { this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::Requesting(authorization_code( cx.emit(DeviceEvent::Requesting(authorization_code(
&app_keys.public_key(), &app_keys.public_key(),
@@ -706,7 +505,7 @@ impl DeviceRegistry {
let task: Task<Result<(), Error>> = cx.background_spawn(async move { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?; let public_key = signer.get_public_key_async().await?;
// Only approvals addressed to this device's client key matter // Only approvals addressed to this device's app key matter
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::Custom(4455)) .kind(Kind::Custom(4455))
.author(public_key) .author(public_key)
@@ -730,18 +529,15 @@ impl DeviceRegistry {
/// Parse the approval event to get encryption key then set it /// Parse the approval event to get encryption key then set it
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) { fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
let app_keys_task = get_or_init_app_keys(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let Some(app_keys) = self.app_keys.clone() else {
log::warn!("Ignoring key transfer addressed to another device");
return;
};
let approval_id = event.id; let approval_id = event.id;
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move { let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
let app_keys = app_keys_task.await?;
let identity = signer.get_public_key_async().await?; let identity = signer.get_public_key_async().await?;
let keys = decrypt_approval(&client, &app_keys, identity, &event).await?; let keys = decrypt_approval(&client, &app_keys, identity, &event).await?;
@@ -753,14 +549,14 @@ impl DeviceRegistry {
Ok(keys) => { Ok(keys) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_signer(keys, cx); this.set_signer(keys, cx);
// The client key was consumed; start fresh on the next request
this.app_keys = None;
// Clean up our request and the approval event from relays // Clean up our request and the approval event from relays
this.cleanup_sync_events(approval_id, cx); this.cleanup_sync_events(approval_id, cx);
})?; })?;
} }
Err(e) => { Err(e) => {
log::warn!("Ignoring key transfer: {e}"); this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::error(e.to_string()));
})?;
} }
} }
Ok(()) Ok(())
@@ -814,58 +610,40 @@ impl DeviceRegistry {
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
// Get user's write relays
let event = event.clone(); let event = event.clone();
let request_id = event.id;
let task: Task<Result<(), Error>> = cx.background_spawn(async move { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Get device keys // Get device keys
let keys = get_keys(&client, &signer).await?; let keys = get_keys(&client, &signer).await?;
let secret = keys.secret_key().to_secret_hex(); let secret = keys.secret_key().to_secret_hex();
let identity = signer.get_public_key_async().await?;
// Extract the target public key from the event tags // Extract the target public key from the event tags
let target = let target = event
client_pubkey_from_event(&event).context("Target is not a valid public key")?; .tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Target is not a valid public key")?;
// The requester may pin the exact key it expects // Encrypt the device keys with the user's signer
if let Some(requested) = requested_encryption_key(&event) let payload = keys.nip44_encrypt_async(&target, &secret).await?;
&& requested != keys.public_key()
{
return Err(anyhow!(
"The requesting device expects an encryption key this device no longer holds"
));
}
// Encrypt to a fresh one-time keypair so the transfer event
// can never be decrypted with the long-lived encryption key.
let sender = Keys::generate();
let payload = sender.nip44_encrypt_async(&target, &secret).await?;
// Construct the response event // Construct the response event
// //
// P tag: the one-time sender key used for the encryption // P tag: the current device's public key
// p tag: the account identity, for inbox routing // p tag: the requester's public key
// p tag: the requester's client key let event = EventBuilder::new(Kind::Custom(4455), payload)
let response = EventBuilder::new(Kind::Custom(4455), payload)
.tags(vec![ .tags(vec![
Tag::custom("P", vec![sender.public_key().to_hex()]), Tag::custom("P", vec![keys.public_key().to_hex()]),
Tag::public_key(identity),
Tag::public_key(target), Tag::public_key(target),
]) ])
.finalize_async(&signer) .finalize_async(&signer)
.await?; .await?;
let relays = merge_relays( // Send the response event to the user's relay list
write_relay_hints(&client, identity).await?, client.send_event(&event).to_nip65().await?;
request_relay_hints(&event),
);
let send = client.send_event(&response);
if relays.is_empty() {
send.to_nip65().await?;
} else {
send.to(relays).await?;
}
Ok(()) Ok(())
}); });
@@ -874,11 +652,8 @@ impl DeviceRegistry {
match task.await { match task.await {
Ok(_) => { Ok(_) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
// Record the request as handled so it cannot resurface
this.mark_request_processed(request_id, cx);
// Allow future requests to surface again // Allow future requests to surface again
this.pending_request = None; this.set_pending_request(false, cx);
cx.notify();
})?; })?;
cx.update(|window, cx| { cx.update(|window, cx| {
@@ -903,70 +678,21 @@ impl DeviceRegistry {
/// Handle encryption request /// Handle encryption request
fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) { fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) {
let Some(client_key) = client_pubkey_from_event(&event) else { // Ignore if there is already a pending request
log::warn!("Ignoring invalid encryption key request: no valid requester key"); if self.pending_request {
return; return;
}; }
self.set_pending_request(true, cx);
let read = credentials::read_processed_requests(cx); let notification = match self.notification(event, cx) {
cx.spawn(async move |this, cx| {
let processed = match read.await {
Ok(bytes) => bytes
.map(|bytes| parse_processed_requests(&bytes).contains(&event.id))
.unwrap_or(false),
Err(e) => {
log::error!("Failed to read processed key-sync requests: {e}");
false
}
};
this.update(cx, |this, cx| {
if processed || this.processed_requests.contains(&event.id) {
return;
}
match this.pending_request.take() {
// A retry signs a new request id but keeps the same client key
Some(mut pending) if pending.client_key == client_key => {
this.mark_request_processed(event.id, cx);
pending.event = event;
this.pending_request = Some(pending);
}
// A different client key is a remounted requester
other => {
if let Some(pending) = other {
this.mark_request_processed(pending.event.id, cx);
if let Some(window) = cx.windows().first().copied() {
window
.update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
}
this.show_approval(event, client_key, cx);
}
}
})
.ok();
})
.detach();
}
/// Build and surface the approval notification for a key request
fn show_approval(&mut self, event: Event, client_key: PublicKey, cx: &mut Context<Self>) {
let notification = match self.notification(event.clone(), cx) {
Ok(notification) => notification, Ok(notification) => notification,
Err(e) => { Err(e) => {
log::warn!("Ignoring invalid encryption key request: {e}"); log::warn!("Ignoring invalid encryption key request: {e}");
self.set_pending_request(false, cx);
return; return;
} }
}; };
self.pending_request = Some(PendingRequest { event, client_key });
cx.notify();
if let Some(window) = cx.windows().first().copied() { if let Some(window) = cx.windows().first().copied() {
if let Err(error) = window.update(cx, |_view, window, cx| { if let Err(error) = window.update(cx, |_view, window, cx| {
window.push_notification(notification, cx); window.push_notification(notification, cx);
@@ -989,16 +715,18 @@ impl DeviceRegistry {
compare the code with the one shown on the requesting \ compare the code with the one shown on the requesting \
device."; device.";
let requester = let requester = event
client_pubkey_from_event(&event).context("Request has no valid requester key")?; .tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Request has no valid requester key")?;
let code = authorization_code(&requester); let code = authorization_code(&requester);
let code_label = code.clone();
let directory = self.directory.clone(); let directory = self.directory.clone();
directory.update(cx, |directory, cx| { directory.update(cx, |directory, cx| directory.track([requester], cx));
directory.track([requester], cx); let code_label = code.clone();
});
let entity = cx.entity().downgrade(); let entity = cx.entity().downgrade();
let loading = Rc::new(Cell::new(false)); let loading = Rc::new(Cell::new(false));
@@ -1103,111 +831,6 @@ impl DeviceRegistry {
struct DeviceNotification; struct DeviceNotification;
/// A key request awaiting user approval on this device
#[derive(Debug)]
struct PendingRequest {
event: Event,
client_key: PublicKey,
}
/// Keep the durable processed-request log bounded
const PROCESSED_REQUESTS_LIMIT: usize = 256;
/// Parse concatenated hex event ids
fn parse_processed_requests(bytes: &[u8]) -> VecDeque<EventId> {
let Ok(hex) = std::str::from_utf8(bytes) else {
return VecDeque::new();
};
hex.as_bytes()
.as_chunks::<64>()
.0
.iter()
.filter_map(|chunk| EventId::from_hex(std::str::from_utf8(chunk).ok()?).ok())
.collect()
}
/// Extract the client public key from an event.
fn client_pubkey_from_event(event: &Event) -> Option<PublicKey> {
let key = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok());
key.or_else(|| {
event
.tags
.iter()
.find(|tag| tag.kind() == "pubkey")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
})
}
/// Whether the transfer event is p-tagged to the given client key
fn transfer_addresses(event: &Event, client_key: PublicKey) -> bool {
event.tags.iter().any(|tag| {
tag.kind() == "p"
&& tag
.content()
.and_then(|content| PublicKey::parse(content).ok())
.is_some_and(|key| key == client_key)
})
}
/// Parse the `relay` hints attached to a key request.
fn request_relay_hints(event: &Event) -> Vec<RelayUrl> {
event
.tags
.iter()
.filter(|tag| tag.kind() == "relay")
.filter_map(|tag| {
tag.content()
.and_then(|content| RelayUrl::parse(content).ok())
})
.collect()
}
/// Parse the optional `n` tag of a key request.
fn requested_encryption_key(event: &Event) -> Option<PublicKey> {
event
.tags
.iter()
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
}
/// Collect the user's NIP-65 write relays.
async fn write_relay_hints(client: &Client, identity: PublicKey) -> Result<Vec<RelayUrl>, Error> {
let filter = Filter::new()
.kind(Kind::RelayList)
.author(identity)
.limit(1);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Ok(vec![]);
};
// Unmarked relays serve both reads and writes
Ok(nip65::extract_relay_list(&event)
.filter(|(_, metadata)| *metadata != Some(RelayMetadata::Read))
.map(|(url, _)| url)
.collect())
}
/// Merge relay lists, dropping duplicates
fn merge_relays(first: Vec<RelayUrl>, second: Vec<RelayUrl>) -> Vec<RelayUrl> {
first
.into_iter()
.chain(second)
.collect::<HashSet<_>>()
.into_iter()
.collect()
}
/// The first eight hexadecimal characters of the client public key. /// The first eight hexadecimal characters of the client public key.
fn authorization_code(public_key: &PublicKey) -> SharedString { fn authorization_code(public_key: &PublicKey) -> SharedString {
let hex = public_key.to_hex(); let hex = public_key.to_hex();
@@ -1215,6 +838,35 @@ fn authorization_code(public_key: &PublicKey) -> SharedString {
SharedString::from(format!("{} {}", &code[..4], &code[4..])) SharedString::from(format!("{} {}", &code[..4], &code[4..]))
} }
/// Get or create new app keys (async, returns a task)
fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
let read = credentials::read_app_keys(cx);
cx.spawn(async move |cx| {
if let Ok(Some(secret)) = read.await
&& let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new)
{
return Ok(keys);
}
// No stored keys found or invalid — generate new ones
let keys = Keys::generate();
let secret = keys.secret_key().to_secret_bytes();
cx.update(|cx| {
let write = credentials::write_app_keys(cx, &secret);
cx.background_spawn(async move {
if let Err(e) = write.await {
log::error!("Failed to persist app keys: {e}")
}
})
.detach();
});
Ok(keys)
})
}
/// Decrypt and validate an approval event against the current announcement. /// Decrypt and validate an approval event against the current announcement.
async fn decrypt_approval( async fn decrypt_approval(
client: &Client, client: &Client,
@@ -1222,7 +874,13 @@ async fn decrypt_approval(
identity: PublicKey, identity: PublicKey,
event: &Event, event: &Event,
) -> Result<Keys, Error> { ) -> Result<Keys, Error> {
let master = client_pubkey_from_event(event).context("Invalid event's tags")?; let master = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Invalid event's tags")?;
let decrypted = app_keys let decrypted = app_keys
.nip44_decrypt_async(&master, event.content.as_str()) .nip44_decrypt_async(&master, event.content.as_str())
@@ -1231,52 +889,71 @@ async fn decrypt_approval(
let secret = SecretKey::from_hex(&decrypted)?; let secret = SecretKey::from_hex(&decrypted)?;
let keys = Keys::new(secret); let keys = Keys::new(secret);
// Reject approvals carrying a key that no longer matches the published announcement // Reject approvals carrying a key that no longer matches the published announcement.
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::Custom(10044)) .kind(Kind::Custom(10044))
.author(identity) .author(identity)
.limit(1); .limit(1);
let announcement = client if let Some(announcement) = client.database().query(filter).await?.into_iter().next() {
.database() let announced = announcement
.query(filter.clone()) .tags
.await? .iter()
.into_iter() .find(|tag| tag.kind() == "n")
.next(); .and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Announcement has no valid encryption key")?;
let announcement = match announcement { if keys.public_key() != announced {
Some(announcement) => Some(announcement), return Err(anyhow!("Approval key doesn't match the announcement"));
None => {
let mut stream = client
.stream_events(filter)
.timeout(Duration::from_secs(3))
.await?;
stream
.next()
.await
.map(|(_url, result)| result)
.transpose()?
} }
};
let Some(announcement) = announcement else {
return Err(anyhow!(
"No encryption announcement available to validate the approval"
));
};
let announced = announcement
.tags
.iter()
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Announcement has no valid encryption key")?;
if keys.public_key() != announced {
return Err(anyhow!("Approval key doesn't match the announcement"));
} }
Ok(keys) Ok(keys)
} }
/// Encrypt and store device keys in the local database.
async fn set_keys<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let content = signer.nip44_encrypt_async(&public_key, secret).await?;
// Construct the application data event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(IDENTIFIER))
.finalize_async(signer)
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
}
/// Get device keys from the local database.
async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(IDENTIFIER)
.author(public_key);
if let Some(event) = client.database().query(filter).await?.first() {
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
let secret = SecretKey::parse(&content)?;
let keys = Keys::new(secret);
Ok(keys)
} else {
Err(anyhow!("Key not found"))
}
}
-174
View File
@@ -1,174 +0,0 @@
use anyhow::{Error, anyhow};
use nostr_sdk::prelude::*;
/// The storage identifier for the device encryption key
const IDENTIFIER: &str = "coop:device";
/// The storage identifier for encryption keys retired by a rotation
const ARCHIVE_IDENTIFIER: &str = "coop:device-archive";
/// Keep the archived key list bounded
const ARCHIVE_LIMIT: usize = 10;
/// Seconds in a day, for rotation intervals
pub(crate) const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
/// Device encryption key material with its creation time
#[derive(Debug, Clone)]
pub(crate) struct DeviceKeys {
pub(crate) keys: Keys,
pub(crate) created_at: Timestamp,
}
/// Encode the stored key content: secret hex and creation time
fn encode_keys_content(secret: &str, created_at: &Timestamp) -> String {
format!("{secret}\n{}", created_at.as_secs())
}
/// Parse stored key content.
fn parse_keys_content(content: &str) -> Result<(SecretKey, Timestamp), Error> {
match content.split_once('\n') {
Some((secret, created_at)) => {
let created_at = created_at
.trim()
.parse::<u64>()
.unwrap_or_else(|_| Timestamp::now().as_secs());
Ok((
SecretKey::from_hex(secret)?,
Timestamp::from_secs(created_at),
))
}
None => Ok((SecretKey::from_hex(content)?, Timestamp::now())),
}
}
/// Parse archived secrets from stored content, one hex secret per line
fn parse_archived_keys(content: &str) -> Vec<Keys> {
content
.lines()
.filter_map(|line| SecretKey::from_hex(line.trim()).ok())
.map(Keys::new)
.collect()
}
/// Load the device keys with their creation time from the local database.
pub(crate) async fn load_device_keys<S>(client: &Client, signer: &S) -> Result<DeviceKeys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(IDENTIFIER)
.author(public_key);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Err(anyhow!("Key not found"));
};
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
let (secret, created_at) = parse_keys_content(&content)?;
Ok(DeviceKeys {
keys: Keys::new(secret),
created_at,
})
}
/// Encrypt and store device keys in the local database.
pub(crate) async fn set_keys<S>(
client: &Client,
signer: &S,
secret: &str,
created_at: Timestamp,
) -> Result<(), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let content = signer
.nip44_encrypt_async(&public_key, &encode_keys_content(secret, &created_at))
.await?;
// Construct the application data event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(IDENTIFIER))
.finalize_async(signer)
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
}
/// Append an encryption secret to the bounded archive of retired keys.
pub(crate) async fn archive_key<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ARCHIVE_IDENTIFIER)
.author(public_key);
let mut archived: Vec<String> = match client.database().query(filter).await?.into_iter().next()
{
Some(event) => {
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
content.lines().map(str::to_string).collect()
}
None => vec![],
};
archived.insert(0, secret.to_string());
archived.truncate(ARCHIVE_LIMIT);
let content = signer
.nip44_encrypt_async(&public_key, &archived.join("\n"))
.await?;
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(ARCHIVE_IDENTIFIER))
.finalize_async(signer)
.await?;
client.database().save_event(&event).await?;
Ok(())
}
/// Get device keys from the local database.
pub(crate) async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
Ok(load_device_keys(client, signer).await?.keys)
}
/// Load encryption keys retired by a rotation from the local database.
pub(crate) async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ARCHIVE_IDENTIFIER)
.author(public_key);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Ok(vec![]);
};
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
Ok(parse_archived_keys(&content))
}
-26
View File
@@ -1,26 +0,0 @@
[package]
name = "nip60"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
state = { path = "../state" }
common = { path = "../common" }
gpui.workspace = true
gpui_tokio.workspace = true
nostr.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
log.workspace = true
flume.workspace = true
serde_json.workspace = true
futures.workspace = true
hkdf.workspace = true
sha2.workspace = true
tokio = { version = "1", default-features = false, features = [ "rt" ] }
cdk.workspace = true
cdk-redb.workspace = true
-369
View File
@@ -1,369 +0,0 @@
use anyhow::{Error, anyhow};
use hkdf::Hkdf;
use nostr::nips::nip60::TokenEvent;
use nostr_sdk::prelude::*;
use sha2::Sha256;
const SEED_SALT: &[u8] = b"coop nip60 seed salt";
const SEED_INFO: &[u8] = b"coop cashu seed";
/// Parsed 17375 wallet event: the wallet key and its mint list.
#[derive(Clone)]
pub(crate) struct WalletData {
pub(crate) event_id: EventId,
pub(crate) created_at: Timestamp,
pub(crate) privkey: SecretKey,
pub(crate) mints: Vec<Url>,
}
impl WalletData {
/// A new wallet with a fresh key.
pub(crate) fn generate(first_mint: Url) -> Self {
Self {
event_id: EventId::from_byte_array([0; 32]),
created_at: Timestamp::from_secs(0),
privkey: SecretKey::generate(),
mints: vec![first_mint],
}
}
/// The cdk seed, derived deterministically from the wallet key.
pub(crate) fn derive_seed(privkey: &SecretKey) -> Result<[u8; 64], Error> {
let ikm = privkey.to_secret_bytes();
let hk = Hkdf::<Sha256>::new(Some(SEED_SALT), &ikm);
let mut seed = [0u8; 64];
hk.expand(SEED_INFO, &mut seed)
.map_err(|error| anyhow!("seed derivation failed: {error}"))?;
Ok(seed)
}
/// Decrypt and parse a 17375 event published by this account.
pub(crate) async fn decrypt<S>(signer: &S, me: &PublicKey, event: &Event) -> Result<Self, Error>
where
S: AsyncNip44 + ?Sized,
{
if event.pubkey != *me {
return Err(anyhow!("wallet event was not published by this account"));
}
let content = signer.nip44_decrypt_async(me, &event.content).await?;
let (privkey, mints) = parse_wallet_content(&content)?;
Ok(Self {
event_id: event.id,
created_at: event.created_at,
privkey,
mints,
})
}
/// Build the encrypted 17375 event for this wallet data.
pub(crate) async fn to_event<S>(&self, signer: &S, me: &PublicKey) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let mut rows = vec![vec!["privkey".to_string(), self.privkey.to_secret_hex()]];
for mint in &self.mints {
rows.push(vec!["mint".to_string(), mint.to_string()]);
}
let content = signer
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
.await?;
Ok(EventBuilder::new(Kind::CashuWallet, content)
.finalize_async(signer)
.await?)
}
}
fn parse_wallet_content(content: &str) -> Result<(SecretKey, Vec<Url>), Error> {
let rows: Vec<Vec<String>> =
serde_json::from_str(content).map_err(|error| anyhow!("invalid wallet event: {error}"))?;
let mut privkey = None;
let mut mints = Vec::new();
for row in rows {
let (Some(key), Some(value)) = (row.first(), row.get(1)) else {
continue;
};
match key.as_str() {
"privkey" if privkey.is_none() => privkey = Some(SecretKey::from_hex(value)?),
"mint" => {
if let Ok(mint) = Url::parse(value) {
mints.push(mint);
}
}
_ => {}
}
}
let Some(privkey) = privkey else {
return Err(anyhow!("wallet event is missing the private key"));
};
if mints.is_empty() {
return Err(anyhow!("wallet event is missing mints"));
}
Ok((privkey, mints))
}
pub(crate) async fn decrypt_token_event<S>(
signer: &S,
me: &PublicKey,
event: &Event,
) -> Result<TokenEvent, Error>
where
S: AsyncNip44 + ?Sized,
{
if event.pubkey != *me {
return Err(anyhow!("token event was not published by this account"));
}
let content = signer.nip44_decrypt_async(me, &event.content).await?;
let token =
serde_json::from_str(&content).map_err(|error| anyhow!("invalid token event: {error}"))?;
Ok(token)
}
pub(crate) async fn build_token_event<S>(
signer: &S,
me: &PublicKey,
token: &TokenEvent,
) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let content = signer
.nip44_encrypt_async(me, &serde_json::to_string(token)?)
.await?;
Ok(EventBuilder::new(Kind::CashuWalletUnspentProof, content)
.finalize_async(signer)
.await?)
}
/// NIP-09 deletion carrying the `k: 7375` tag required by NIP-60.
pub(crate) async fn build_token_deletion<S>(signer: &S, ids: &[EventId]) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let mut tags: Vec<Tag> = ids.iter().map(|id| Tag::event(*id)).collect();
tags.push(Tag::custom("k", ["7375".to_string()]));
Ok(EventBuilder::new(Kind::EventDeletion, "")
.tags(tags)
.finalize_async(signer)
.await?)
}
/// Decrypted 7376 payload, enough for the wallet panel's history list.
pub(crate) struct HistoryPayload {
pub(crate) direction: TransactionDirection,
pub(crate) amount: u64,
}
pub(crate) async fn decrypt_history_event<S>(
signer: &S,
me: &PublicKey,
event: &Event,
) -> Result<HistoryPayload, Error>
where
S: AsyncNip44 + ?Sized,
{
if event.pubkey != *me {
return Err(anyhow!("history event was not published by this account"));
}
let content = signer.nip44_decrypt_async(me, &event.content).await?;
let rows: Vec<Vec<String>> = serde_json::from_str(&content)
.map_err(|error| anyhow!("invalid history event: {error}"))?;
let mut direction = None;
let mut amount = None;
for row in rows {
let (Some(key), Some(value)) = (row.first(), row.get(1)) else {
continue;
};
match key.as_str() {
"direction" if direction.is_none() => direction = value.parse().ok(),
"amount" if amount.is_none() => amount = value.parse().ok(),
_ => {}
}
}
let Some(direction) = direction else {
return Err(anyhow!("history event is missing the direction"));
};
let Some(amount) = amount else {
return Err(anyhow!("history event is missing the amount"));
};
Ok(HistoryPayload { direction, amount })
}
/// Build the public kind 9321 nutzap event carrying P2PK-locked proofs.
pub(crate) async fn build_nutzap_event<S>(
signer: &S,
recipient: PublicKey,
mint: &str,
proofs: &[String],
target: Option<EventId>,
comment: &str,
) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let mut tags: Vec<Tag> = vec![
Tag::custom("p", [recipient.to_hex()]),
Tag::custom("u", [mint.to_string()]),
Tag::custom("unit", ["sat".to_string()]),
];
for proof in proofs {
tags.push(Tag::custom("proof", [proof.clone()]));
}
if let Some(target) = target {
tags.push(Tag::custom("e", [target.to_hex()]));
}
Ok(EventBuilder::new(Kind::Custom(9321), comment)
.tags(tags)
.finalize_async(signer)
.await?)
}
/// Encrypted 7376 history payload for one state change.
pub(crate) struct History {
pub(crate) direction: TransactionDirection,
pub(crate) amount: u64,
/// NIP-61 nutzap events redeemed by this state change
pub(crate) redeemed: Vec<EventId>,
/// Author of the redeemed nutzap, for the unencrypted `p` tag
pub(crate) sender: Option<PublicKey>,
}
pub(crate) async fn build_history_event<S>(
signer: &S,
me: &PublicKey,
history: &History,
created: EventId,
destroyed: &[EventId],
) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let mut rows = vec![
vec!["direction".to_string(), history.direction.to_string()],
vec!["amount".to_string(), history.amount.to_string()],
];
for id in destroyed {
rows.push(vec![
"e".to_string(),
id.to_hex(),
String::new(),
"destroyed".to_string(),
]);
}
rows.push(vec![
"e".to_string(),
created.to_hex(),
String::new(),
"created".to_string(),
]);
let mut tags: Vec<Tag> = history
.redeemed
.iter()
.map(|id| Tag::custom("e", [id.to_hex(), String::new(), "redeemed".to_string()]))
.collect();
if let Some(sender) = history.sender {
tags.push(Tag::custom("p", [sender.to_hex()]));
}
let content = signer
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
.await?;
Ok(EventBuilder::new(Kind::CashuWalletSpendingHistory, content)
.tags(tags)
.finalize_async(signer)
.await?)
}
pub(crate) async fn build_nutzap_info_event<S>(
signer: &S,
mints: &[Url],
relays: &[RelayUrl],
wallet_pubkey: PublicKey,
) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let mut tags: Vec<Tag> = relays
.iter()
.map(|relay| Tag::custom("relay", [relay.to_string()]))
.collect();
for mint in mints {
tags.push(Tag::custom("mint", [mint.to_string(), "sat".to_string()]));
}
tags.push(Tag::custom(
"pubkey",
[format!("02{}", wallet_pubkey.to_hex())],
));
Ok(EventBuilder::new(Kind::Custom(10019), "")
.tags(tags)
.finalize_async(signer)
.await?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_wallet_content() {
let privkey = SecretKey::generate();
let mint = Url::parse("https://mint.example.com").expect("valid mint URL");
let content = serde_json::to_string(&vec![
vec!["privkey".to_string(), privkey.to_secret_hex()],
vec!["mint".to_string(), mint.to_string()],
])
.expect("serialization cannot fail");
let (parsed_key, mints) = parse_wallet_content(&content).expect("valid content");
assert_eq!(parsed_key.to_secret_hex(), privkey.to_secret_hex());
assert_eq!(mints, vec![mint]);
}
#[test]
fn wallet_content_rejects_missing_privkey() {
let mint = Url::parse("https://mint.example.com").expect("valid mint URL");
let content = serde_json::to_string(&vec![vec!["mint".to_string(), mint.to_string()]])
.expect("serialization cannot fail");
assert!(parse_wallet_content(&content).is_err());
}
#[test]
fn seed_is_deterministic_per_key() {
let key = SecretKey::generate();
let first = WalletData::derive_seed(&key).expect("seed derivation cannot fail");
let second = WalletData::derive_seed(&key).expect("seed derivation cannot fail");
assert_eq!(first, second);
let other =
WalletData::derive_seed(&SecretKey::generate()).expect("seed derivation cannot fail");
assert_ne!(first, other);
}
}
File diff suppressed because it is too large Load Diff
-617
View File
@@ -1,617 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::time::Duration;
use anyhow::{Error, anyhow};
use cdk::Amount;
use cdk::amount::SplitTarget;
use cdk::mint_url::MintUrl;
use cdk::nuts::nut00::KnownMethod;
use cdk::nuts::{
CurrencyUnit, Id, KeySetInfo, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, Proofs,
SecretKey, SpendingConditions, State,
};
use cdk::secret::Secret;
use cdk::wallet::types::ProofInfo;
use cdk::wallet::{ReceiveOptions, SendOptions, Wallet};
use futures::StreamExt;
use nostr::nips::nip60::{CashuProof, TokenEvent, TransactionDirection};
use nostr_sdk::prelude::*;
use crate::keys;
/// How long to wait for relays when fetching a recipient's wallet info
const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
/// A job mutating the wallet store or reconciling it with relays.
#[derive(Debug, Clone)]
pub(crate) enum Job {
Reconcile,
CreateQuote {
mint: MintUrl,
amount: u64,
},
ClaimQuote {
mint: MintUrl,
quote_id: String,
},
Melt {
mint: MintUrl,
invoice: String,
},
SendToken {
mint: MintUrl,
amount: u64,
},
SendNutzap {
recipient: PublicKey,
amount: u64,
target: Option<EventId>,
comment: Option<String>,
},
Receive {
mint: MintUrl,
token: String,
},
RedeemNutzap {
mint: MintUrl,
proofs: Proofs,
nutzap_id: EventId,
sender: PublicKey,
},
}
impl Job {
pub(crate) async fn run<S>(self, ctx: JobCtx<S>) -> Result<Outcome, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static,
{
match self {
Job::Reconcile => ctx.reconcile().await,
Job::CreateQuote { mint, amount } => {
let wallet = ctx.wallet(&mint)?;
let quote = wallet
.mint_quote(
PaymentMethod::Known(KnownMethod::Bolt11),
Some(Amount::from(amount)),
None,
None,
)
.await?;
Ok(Outcome::QuoteCreated {
mint,
quote_id: quote.id,
request: quote.request,
amount,
})
}
Job::ClaimQuote { mint, quote_id } => {
let wallet = ctx.wallet(&mint)?;
let quote = wallet.check_mint_quote(&quote_id).await?;
if quote.state != MintQuoteState::Paid {
return Ok(Outcome::Idle);
}
let proofs = wallet.mint(&quote_id, SplitTarget::default(), None).await?;
let amount: u64 = proofs.iter().map(|proof| u64::from(proof.amount)).sum();
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint)?,
&ctx.previous_ids(&mint),
Some(keys::History {
direction: TransactionDirection::In,
amount: u64::from(quote.amount.unwrap_or_default()),
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::QuoteMinted {
quote_id,
update,
amount,
})
}
Job::Melt { mint, invoice } => {
let wallet = ctx.wallet(&mint)?;
let quote = wallet
.melt_quote(
PaymentMethod::Known(KnownMethod::Bolt11),
invoice,
None,
None,
)
.await?;
let finalized = wallet
.prepare_melt(&quote.id, HashMap::new())
.await?
.confirm()
.await?;
if finalized.state() != MeltQuoteState::Paid {
return Err(anyhow!("melt did not settle: {:?}", finalized.state()));
}
let amount = u64::from(finalized.total_amount());
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint)?,
&ctx.previous_ids(&mint),
Some(keys::History {
direction: TransactionDirection::Out,
amount,
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::Melted { update, amount })
}
Job::SendToken { mint, amount } => {
let wallet = ctx.wallet(&mint)?;
let prepared = wallet
.prepare_send(Amount::from(amount), SendOptions::default())
.await?;
let token = prepared.confirm(None).await?.to_string();
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint)?,
&ctx.previous_ids(&mint),
Some(keys::History {
direction: TransactionDirection::Out,
amount,
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::Sent { update, token })
}
Job::SendNutzap {
recipient,
amount,
target,
comment,
} => {
let (mint_url, mint_tag, p2pk) = recipient_wallet_info(&ctx, recipient).await?;
let wallet = ctx.wallet(&mint_url)?;
let keysets = wallet
.keysets(cdk::wallet::types::KeysetLoadPolicy::CacheOnly)
.await?;
let infos: Vec<KeySetInfo> = keysets
.iter()
.map(|keyset| KeySetInfo {
id: keyset.id,
unit: keyset.unit.clone(),
active: keyset.active.unwrap_or(true),
input_fee_ppk: keyset.input_fee_ppk,
final_expiry: keyset.final_expiry,
})
.collect();
let options = SendOptions {
conditions: Some(SpendingConditions::new_p2pk(p2pk, None)),
..Default::default()
};
let token = wallet
.prepare_send(Amount::from(amount), options)
.await?
.confirm(None)
.await?;
let proofs = token.proofs(&infos)?;
let mut tags = Vec::new();
for proof in &proofs {
tags.push(serde_json::to_string(proof)?);
}
let event = keys::build_nutzap_event(
&ctx.signer,
recipient,
&mint_tag,
&tags,
target,
comment.as_deref().unwrap_or(""),
)
.await?;
ctx.client.send_event(&event).await?;
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint_url)?,
&ctx.previous_ids(&mint_url),
Some(keys::History {
direction: TransactionDirection::Out,
amount,
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::NutzapSent { update, amount })
}
Job::Receive { mint, token } => {
let wallet = ctx.wallet(&mint)?;
let received = wallet.receive(&token, ReceiveOptions::default()).await?;
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint)?,
&ctx.previous_ids(&mint),
Some(keys::History {
direction: TransactionDirection::In,
amount: u64::from(received),
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::Received {
update,
amount: u64::from(received),
})
}
Job::RedeemNutzap {
mint,
proofs,
nutzap_id,
sender,
} => {
let wallet = ctx.wallet(&mint)?;
let options = ReceiveOptions {
p2pk_signing_keys: vec![ctx.wallet_key.clone()],
..Default::default()
};
let received = wallet.receive_proofs(proofs, options, None, None).await?;
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint)?,
&ctx.previous_ids(&mint),
Some(keys::History {
direction: TransactionDirection::In,
amount: u64::from(received),
redeemed: vec![nutzap_id],
sender: Some(sender),
}),
)
.await?;
Ok(Outcome::NutzapRedeemed {
update,
nutzap_id,
amount: u64::from(received),
})
}
}
}
}
pub(crate) struct TokenUpdate {
pub(crate) event_id: EventId,
pub(crate) token: TokenEvent,
pub(crate) destroyed: Vec<EventId>,
}
pub(crate) enum Outcome {
/// Nothing changed (e.g. an unpaid quote)
Idle,
/// Token events published by a reconcile pass
Reconciled {
updates: Vec<TokenUpdate>,
},
QuoteCreated {
mint: MintUrl,
quote_id: String,
request: String,
amount: u64,
},
QuoteMinted {
quote_id: String,
update: TokenUpdate,
amount: u64,
},
Melted {
update: TokenUpdate,
amount: u64,
},
Sent {
update: TokenUpdate,
token: String,
},
NutzapSent {
update: TokenUpdate,
amount: u64,
},
Received {
update: TokenUpdate,
amount: u64,
},
NutzapRedeemed {
update: TokenUpdate,
nutzap_id: EventId,
amount: u64,
},
}
pub(crate) struct JobCtx<S> {
pub(crate) wallets: HashMap<MintUrl, Wallet>,
/// Wallet signing key, unlocking P2PK-locked nutzap proofs
pub(crate) wallet_key: SecretKey,
pub(crate) signer: S,
pub(crate) client: Client,
/// Relay token events at schedule time.
pub(crate) tokens: HashMap<EventId, TokenEvent>,
}
impl<S> JobCtx<S>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync,
{
fn wallet(&self, mint: &MintUrl) -> Result<&Wallet, Error> {
self.wallets
.get(mint)
.ok_or_else(|| anyhow!("no open wallet for mint {mint}"))
}
fn previous_ids(&self, mint: &MintUrl) -> Vec<EventId> {
previous_ids(&self.tokens, mint)
}
/// Publish the store's current unspent set as a fresh token event.
async fn sync_token_events(
&self,
wallet: &Wallet,
mint: Url,
previous: &[EventId],
history: Option<keys::History>,
) -> Result<TokenUpdate, Error> {
let me = self.signer.get_public_key_async().await?;
let unspent = wallet.get_unspent_proofs().await?;
let proofs = unspent
.iter()
.map(cashu_proof)
.collect::<Result<Vec<_>, _>>()?;
let mut token = TokenEvent::new(mint, proofs);
token.del = previous.iter().map(|id| id.to_hex()).collect();
let event = keys::build_token_event(&self.signer, &me, &token).await?;
self.client.send_event(&event).await?;
if !previous.is_empty() {
let deletion = keys::build_token_deletion(&self.signer, previous).await?;
self.client.send_event(&deletion).await?;
}
if let Some(history) = history {
let history_event =
keys::build_history_event(&self.signer, &me, &history, event.id, previous).await?;
self.client.send_event(&history_event).await?;
}
Ok(TokenUpdate {
event_id: event.id,
token,
destroyed: previous.to_vec(),
})
}
/// Claim paid quotes, then converge each mint.
async fn reconcile(self) -> Result<Outcome, Error> {
let mut tokens = self.tokens.clone();
let mut updates = Vec::new();
for (mint_url, wallet) in &self.wallets {
if let Err(error) = wallet.recover_incomplete_sagas().await {
log::warn!("saga recovery failed for {mint_url}: {error:#}");
}
let minted = wallet.mint_unissued_quotes().await?;
if minted == Amount::ZERO {
continue;
}
let update = self
.sync_token_events(
wallet,
nostr_mint(mint_url)?,
&previous_ids(&tokens, mint_url),
Some(keys::History {
direction: TransactionDirection::In,
amount: u64::from(minted),
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
for id in &update.destroyed {
tokens.remove(id);
}
tokens.insert(update.event_id, update.token.clone());
updates.push(update);
}
for (mint_url, wallet) in &self.wallets {
let mut relay_ys = HashSet::new();
let mut added = Vec::new();
for token in tokens
.values()
.filter(|token| token.mint.as_str() == mint_url.to_string())
{
for proof in &token.proofs {
let info = match proof_info(proof, mint_url) {
Ok(info) => info,
Err(error) => {
log::warn!("skipping proof: {error:#}");
continue;
}
};
if relay_ys.insert(info.y) {
added.push(info);
}
}
}
let unspent = wallet.get_unspent_proofs().await?;
let removed = unspent
.iter()
.filter(|proof| match proof.y() {
Ok(y) => !relay_ys.contains(&y),
Err(_) => false,
})
.map(|proof| proof.y())
.collect::<Result<Vec<_>, _>>()?;
wallet.localstore.update_proofs(added, removed).await?;
}
Ok(Outcome::Reconciled { updates })
}
}
fn previous_ids(tokens: &HashMap<EventId, TokenEvent>, mint: &MintUrl) -> Vec<EventId> {
tokens
.iter()
.filter(|(_, token)| token.mint.as_str() == mint.to_string())
.map(|(id, _)| *id)
.collect()
}
/// Resolve a recipient's 10019 wallet info and pick a mint we share with them.
async fn recipient_wallet_info<S>(
ctx: &JobCtx<S>,
recipient: PublicKey,
) -> Result<(MintUrl, String, cdk::nuts::PublicKey), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync,
{
let filter = Filter::new()
.author(recipient)
.kind(Kind::Custom(10019))
.limit(1);
let info = match ctx
.client
.database()
.query(filter.clone())
.await?
.into_iter()
.next()
{
Some(event) => event,
None => {
let mut stream = ctx
.client
.stream_events(filter)
.timeout(FETCH_TIMEOUT)
.await?;
let mut found: Option<Event> = None;
while let Some((_url, result)) = stream.next().await {
if let Ok(event) = result {
let newer = found
.as_ref()
.is_none_or(|current| event.created_at > current.created_at);
if newer {
found = Some(event);
}
}
}
found.ok_or_else(|| anyhow!("recipient has no wallet info"))?
}
};
let (p2pk, mints) = parse_nutzap_info(&info)?;
for mint in mints {
let Ok(mint_url) = MintUrl::from_str(&mint) else {
continue;
};
if ctx.wallets.contains_key(&mint_url) {
return Ok((mint_url, mint, p2pk));
}
}
Err(anyhow!("no shared mint with the recipient"))
}
fn parse_nutzap_info(event: &Event) -> Result<(cdk::nuts::PublicKey, Vec<String>), Error> {
let mut p2pk = None;
let mut mints = Vec::new();
for tag in event.tags.iter() {
let Some(value) = tag.content() else {
continue;
};
match tag.kind() {
"pubkey" if p2pk.is_none() => {
p2pk = Some(cdk::nuts::PublicKey::from_str(value)?);
}
"mint" => mints.push(value.to_string()),
_ => {}
}
}
let Some(p2pk) = p2pk else {
return Err(anyhow!("recipient has no wallet pubkey"));
};
Ok((p2pk, mints))
}
fn cashu_proof(proof: &Proof) -> Result<CashuProof, Error> {
Ok(CashuProof {
id: proof.keyset_id.to_string(),
amount: u64::from(proof.amount),
secret: proof.secret.to_string(),
c: proof.c.to_string(),
})
}
fn nostr_mint(mint: &MintUrl) -> Result<Url, Error> {
Url::parse(&mint.to_string()).map_err(|error| anyhow!("invalid mint url: {error}"))
}
fn proof_info(proof: &CashuProof, mint: &MintUrl) -> Result<ProofInfo, Error> {
let keyset_id = Id::from_str(&proof.id)?;
let c = cdk::nuts::PublicKey::from_str(&proof.c)?;
let info = ProofInfo::new(
Proof::new(
Amount::from(proof.amount),
keyset_id,
Secret::new(proof.secret.clone()),
c,
),
mint.clone(),
State::Unspent,
CurrencyUnit::Sat,
)?;
Ok(info)
}
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "nip60_ui"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
nip60 = { path = "../nip60" }
gpui.workspace = true
gpui-kit.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
-79
View File
@@ -1,79 +0,0 @@
#![cfg(not(target_arch = "wasm32"))]
use std::rc::Rc;
use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px};
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{WindowExt, h_flex, v_flex};
use nip60::WalletRegistry;
use nostr_sdk::prelude::{EventId, PublicKey};
pub mod wallet;
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
/// Ask for an amount and send a nutzap to `recipient`, optionally
/// nutzapping the event with `target`.
pub fn open_nutzap(
window: &mut Window,
cx: &mut App,
recipient: PublicKey,
target: Option<EventId>,
) {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)"));
let send: SubmitFn = {
let input = input.clone();
Rc::new(move |_, window, cx| {
let amount = match input.read(cx).value().trim().parse::<u64>() {
Ok(amount) if amount > 0 => amount,
_ => {
window.push_notification(Notification::error("Enter an amount in sats"), cx);
return false;
}
};
let wallet = WalletRegistry::global(cx);
wallet.update(cx, |registry, cx| {
registry.send_nutzap(recipient, amount, target, None, cx);
});
true
})
};
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(380.))
.title("Send Nutzap")
.on_ok({
let send = send.clone();
move |event, window, cx| send(event, window, cx)
})
.child(
v_flex()
.gap_3()
.child(Input::new(&input).cleanable(true))
.child(
h_flex()
.gap_1()
.justify_end()
.child(
Button::new("cancel")
.label("Cancel")
.ghost()
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(Button::new("send").label("Send").primary().on_click({
let send = send.clone();
move |event, window, cx| {
if send(event, window, cx) {
window.close_dialog(cx);
}
}
})),
),
)
});
}
-422
View File
@@ -1,422 +0,0 @@
use ::nip60::{HistoryEntry, MintQuoteEntry, WalletEvent, WalletRegistry};
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
ParentElement, Render, SharedString, Styled, Subscription, Window, div,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::scroll::ScrollableElement as _;
use gpui_kit::component::separator::Separator;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*;
use smallvec::SmallVec;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<WalletPanel> {
cx.new(|cx| WalletPanel::new(window, cx))
}
pub struct WalletPanel {
name: SharedString,
focus_handle: FocusHandle,
/// Mint URL input for the setup flow
mint_input: Entity<InputState>,
/// Amount input for sending a cashu token
send_input: Entity<InputState>,
/// Amount input for the funding invoice
invoice_input: Entity<InputState>,
/// Pasted token input for receiving ecash
token_input: Entity<InputState>,
/// The token produced by the last send, shown for copying
sent_token: Option<SharedString>,
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl WalletPanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint…"));
let send_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)"));
let invoice_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)"));
let token_input = cx.new(|cx| InputState::new(window, cx).placeholder("cashu…"));
let wallet = WalletRegistry::global(cx);
let mut subscriptions = SmallVec::new();
subscriptions.push(
cx.subscribe_in(
&wallet,
window,
|this, _wallet, event, window, cx| match event {
WalletEvent::Updated => cx.notify(),
WalletEvent::Error(error) => {
window.push_notification(Notification::error(error.clone()), cx);
}
WalletEvent::TokenCreated(token) => {
this.sent_token = Some(token.clone().into());
cx.notify();
}
},
),
);
Self {
name: "Wallet".into(),
focus_handle: cx.focus_handle(),
mint_input,
send_input,
invoice_input,
token_input,
sent_token: None,
_subscriptions: subscriptions,
}
}
fn parse_amount(&self, input: &Entity<InputState>, cx: &Context<Self>) -> Option<u64> {
match input.read(cx).value().trim().parse::<u64>() {
Ok(amount) if amount > 0 => Some(amount),
_ => None,
}
}
fn setup(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let mint = self.mint_input.read(cx).value().trim().to_string();
if mint.is_empty() {
window.push_notification(Notification::error("Enter a mint URL"), cx);
return;
}
WalletRegistry::global(cx).update(cx, |registry, cx| {
registry.setup(mint, cx);
});
}
fn send_token(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(amount) = self.parse_amount(&self.send_input, cx) else {
window.push_notification(Notification::error("Enter an amount in sats"), cx);
return;
};
WalletRegistry::global(cx).update(cx, |registry, cx| {
registry.send_token(amount, cx);
});
}
fn create_invoice(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(amount) = self.parse_amount(&self.invoice_input, cx) else {
window.push_notification(Notification::error("Enter an amount in sats"), cx);
return;
};
WalletRegistry::global(cx).update(cx, |registry, cx| {
registry.create_mint_quote(amount, cx);
});
}
fn claim_quote(&mut self, quote_id: String, cx: &mut Context<Self>) {
WalletRegistry::global(cx).update(cx, |registry, cx| {
registry.claim_mint_quote(quote_id, cx);
});
}
fn receive_token(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let token = self.token_input.read(cx).value().trim().to_string();
if token.is_empty() {
window.push_notification(Notification::error("Paste a cashu token"), cx);
return;
}
WalletRegistry::global(cx).update(cx, |registry, cx| {
registry.receive_token(token, cx);
});
}
fn render_balance(&self, cx: &Context<Self>) -> impl IntoElement {
let balance = WalletRegistry::global(cx).read(cx).balance();
h_flex()
.gap_2()
.items_end()
.child(
div()
.text_2xl()
.child(SharedString::from(balance.to_string())),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("sats"),
)
}
fn render_setup(&self, cx: &mut Context<Self>) -> impl IntoElement {
let has_wallet = WalletRegistry::global(cx).read(cx).has_wallet();
v_flex()
.gap_1p5()
.w_full()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(if has_wallet {
"Add a mint:"
} else {
"Set up your wallet with a mint:"
})),
)
.child(
h_flex()
.gap_1()
.w_full()
.child(Input::new(&self.mint_input).cleanable(true))
.child(
Button::new("setup")
.label(if has_wallet { "Add" } else { "Set up" })
.primary()
.small()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.setup(window, cx);
})),
),
)
}
fn render_send(&self, cx: &Context<Self>) -> impl IntoElement {
v_flex()
.gap_1p5()
.w_full()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Send ecash:"),
)
.child(
h_flex()
.gap_1()
.w_full()
.child(Input::new(&self.send_input).cleanable(true))
.child(
Button::new("send")
.label("Send")
.small()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.send_token(window, cx);
})),
),
)
.when_some(self.sent_token.as_ref(), |this, token| {
this.child(
h_flex()
.gap_1()
.w_full()
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_color(cx.theme().muted_foreground)
.child(token.clone()),
)
.child(
Clipboard::new("copy-token")
.accessibility_label("Copy cashu token")
.value_fn({
let token = token.clone();
move |_, _| SharedString::from(token.to_string())
}),
),
)
})
}
fn render_receive(&self, cx: &Context<Self>) -> impl IntoElement {
let quotes = WalletRegistry::global(cx).read(cx).mint_quotes().to_vec();
let mut section =
v_flex()
.gap_1p5()
.w_full()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Receive:"),
)
.child(
h_flex()
.gap_1()
.w_full()
.child(Input::new(&self.invoice_input).cleanable(true))
.child(Button::new("invoice").label("Invoice").small().on_click(
cx.listener(move |this, _ev, window, cx| {
this.create_invoice(window, cx);
}),
)),
);
for quote in quotes {
section = section.child(self.render_quote(&quote, cx));
}
section.child(
h_flex()
.gap_1()
.w_full()
.child(Input::new(&self.token_input).cleanable(true))
.child(
Button::new("receive")
.label("Redeem")
.small()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.receive_token(window, cx);
})),
),
)
}
fn render_quote(&self, quote: &MintQuoteEntry, cx: &Context<Self>) -> impl IntoElement {
h_flex()
.gap_1()
.w_full()
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(quote.request.clone())),
)
.child(
Clipboard::new(SharedString::from(format!("copy_{}", quote.quote_id)))
.accessibility_label("Copy lightning invoice")
.value_fn({
let request = SharedString::from(quote.request.clone());
move |_, _| request.clone()
}),
)
.child(
Button::new(SharedString::from(format!("claim_{}", quote.quote_id)))
.label("Claim")
.small()
.on_click({
let quote_id = quote.quote_id.clone();
cx.listener(move |this, _ev, _window, cx| {
this.claim_quote(quote_id.clone(), cx);
})
}),
)
}
fn render_history(&self, cx: &Context<Self>) -> impl IntoElement {
let history = WalletRegistry::global(cx).read(cx).history().to_vec();
let mut section = v_flex().gap_1().w_full().child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("History:"),
);
if history.is_empty() {
section = section.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("No transactions yet."),
);
}
for entry in history {
section = section.child(self.render_history_entry(&entry, cx));
}
section
}
fn render_history_entry(&self, entry: &HistoryEntry, cx: &Context<Self>) -> impl IntoElement {
let (label, color) = match entry.direction {
TransactionDirection::In => ("Received", cx.theme().success),
TransactionDirection::Out => ("Sent", cx.theme().danger),
};
h_flex()
.justify_between()
.w_full()
.py_0p5()
.text_xs()
.child(
h_flex()
.gap_2()
.child(div().text_color(color).child(label))
.child(SharedString::from(format!("{} sats", entry.amount))),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(entry.created_at.to_human_time()),
)
}
}
impl gpui_kit::component::dock::BasePanel for WalletPanel {
fn panel_name(&self) -> &'static str {
"Wallet"
}
}
impl Panel for WalletPanel {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Wallet)
.small()
.text_color(cx.theme().muted_foreground),
)
.child(self.name.clone())
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for WalletPanel {}
impl Focusable for WalletPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for WalletPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let has_wallet = WalletRegistry::global(cx).read(cx).has_wallet();
v_flex()
.p_3()
.gap_3()
.w_full()
.overflow_y_scrollbar()
.text_xs()
.when(!has_wallet, |this| this.child(self.render_setup(cx)))
.when(has_wallet, |this| {
this.child(self.render_balance(cx))
.child(Separator::horizontal())
.child(self.render_send(cx))
.child(self.render_receive(cx))
.child(Separator::horizontal())
.child(self.render_history(cx))
.child(self.render_setup(cx))
})
}
}
+3 -7
View File
@@ -54,7 +54,7 @@ impl EventEmitter<PersonEvent> for PersonRegistry {}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum Dispatch { enum Dispatch {
Person(Box<Person>), Person(Person),
Announcement(Event), Announcement(Event),
Relays(Event), Relays(Event),
Emojis(Event), Emojis(Event),
@@ -111,7 +111,7 @@ impl PersonRegistry {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
match event { match event {
Dispatch::Person(person) => { Dispatch::Person(person) => {
this.insert(*person, cx); this.insert(person, cx);
} }
Dispatch::Announcement(event) => { Dispatch::Announcement(event) => {
this.set_announcement(&event, cx); this.set_announcement(&event, cx);
@@ -175,11 +175,7 @@ impl PersonRegistry {
let metadata = Metadata::from_json(&event.content).unwrap_or_default(); let metadata = Metadata::from_json(&event.content).unwrap_or_default();
let person = Person::new(event.pubkey, metadata); let person = Person::new(event.pubkey, metadata);
if tx if tx.send_async(Dispatch::Person(person)).await.is_err() {
.send_async(Dispatch::Person(Box::new(person)))
.await
.is_err()
{
log::warn!("Channel closed, dropping metadata event"); log::warn!("Channel closed, dropping metadata event");
} }
} }
-7
View File
@@ -144,13 +144,6 @@ impl Person {
/// Set profile encryption keys announcement /// Set profile encryption keys announcement
pub(crate) fn set_announcement(&mut self, announcement: Announcement) { pub(crate) fn set_announcement(&mut self, announcement: Announcement) {
// A delayed or repeated announcement must not roll the key back
if let Some(current) = &self.announcement
&& !announcement.is_newer_than(current)
{
return;
}
self.announcement = Some(announcement); self.announcement = Some(announcement);
} }
+14 -43
View File
@@ -100,12 +100,7 @@ pub fn persisted_theme() -> ThemeSettings {
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/"; const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
const LEGACY_FILE_SERVER: &str = "blossom.band"; const LEGACY_FILE_SERVER: &str = "blossom.band";
/// Minimum days between automatic encryption key rotations
pub const NIP4E_ROTATION_MIN_DAYS: u32 = 1;
/// Maximum days between automatic encryption key rotations
pub const NIP4E_ROTATION_MAX_DAYS: u32 = 90;
/// Days between automatic encryption key rotations when enabled
pub const NIP4E_ROTATION_DEFAULT_DAYS: u32 = 30;
/// Built-in image shown behind chat and group panels /// Built-in image shown behind chat and group panels
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg"; pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
@@ -152,7 +147,6 @@ setting_accessors! {
pub screening: bool, pub screening: bool,
pub wot_auto_trust: bool, pub wot_auto_trust: bool,
pub nip4e: bool, pub nip4e: bool,
pub nip4e_rotation_days: Option<u32>,
pub hide_group_notice: bool, pub hide_group_notice: bool,
pub file_server: Url, pub file_server: Url,
pub panel_background: bool, pub panel_background: bool,
@@ -228,9 +222,6 @@ pub struct Settings {
pub wot_auto_trust: bool, pub wot_auto_trust: bool,
/// Enable decoupling encryption key /// Enable decoupling encryption key
pub nip4e: bool, pub nip4e: bool,
/// Days between automatic encryption key rotations; None disables rotation
#[serde(default)]
pub nip4e_rotation_days: Option<u32>,
/// Hide the notice that messages in public groups are not encrypted /// Hide the notice that messages in public groups are not encrypted
#[serde(default)] #[serde(default)]
pub hide_group_notice: bool, pub hide_group_notice: bool,
@@ -263,7 +254,6 @@ impl Default for Settings {
screening: true, screening: true,
wot_auto_trust: true, wot_auto_trust: true,
nip4e: false, nip4e: false,
nip4e_rotation_days: None,
hide_group_notice: false, hide_group_notice: false,
theme: ThemeSettings::default(), theme: ThemeSettings::default(),
panel_background: true, panel_background: true,
@@ -340,40 +330,22 @@ impl AppSettings {
}); });
} }
/// Apply the persisted theme tuning on top of the active theme
pub fn apply_theme_style(cx: &mut App) { pub fn apply_theme_style(cx: &mut App) {
Self::apply_theme_tuning(Self::get_theme(cx), cx);
}
fn apply_theme_tuning(tuning: ThemeSettings, cx: &mut App) {
use gpui_kit::component::Theme; use gpui_kit::component::Theme;
{ let theme = Self::get_theme(cx);
let styled = Theme::global_mut(cx); let styled = Theme::global_mut(cx);
styled.font_size = px(tuning.font_size); styled.font_size = px(theme.font_size);
styled.focus_ring = tuning.focus_ring; styled.focus_ring = theme.focus_ring;
if let Some(radius) = tuning.radius { if let Some(radius) = theme.radius {
styled.radius = px(radius); styled.radius = px(radius);
}
if let Some(radius_lg) = tuning.radius_lg {
styled.radius_lg = px(radius_lg);
}
} }
if let Some(radius_lg) = theme.radius_lg {
Theme::update(cx, |styled| { styled.radius_lg = px(radius_lg);
let background = styled.background; }
let foreground = styled.foreground; Theme::sync_base(cx);
let (hover_alpha, active_alpha) = if styled.is_dark() { cx.refresh_windows();
(0.08, 0.12)
} else {
(0.04, 0.06)
};
if styled.list_hover == background {
styled.list_hover = foreground.alpha(hover_alpha);
}
if styled.tab_active == background {
styled.tab_active = foreground.alpha(active_alpha);
}
});
} }
/// Load settings /// Load settings
@@ -444,8 +416,7 @@ impl AppSettings {
AppearanceMode::Light => Theme::change(gpui_kit::component::ThemeMode::Light, None, cx), AppearanceMode::Light => Theme::change(gpui_kit::component::ThemeMode::Light, None, cx),
AppearanceMode::Dark => Theme::change(gpui_kit::component::ThemeMode::Dark, None, cx), AppearanceMode::Dark => Theme::change(gpui_kit::component::ThemeMode::Dark, None, cx),
} }
let tuning = self.inner.read(cx).theme; cx.refresh_windows();
Self::apply_theme_tuning(tuning, cx);
} }
/// Check if decoupling encryption key is enabled /// Check if decoupling encryption key is enabled
+8 -8
View File
@@ -5,8 +5,8 @@ use gpui::{App, AppContext as _, Task};
use crate::USER_KEYRING; use crate::USER_KEYRING;
/// The storage key for the log of handled NIP-4e key-sync request ids /// The storage key for the NIP-4e app keys
const PROCESSED_REQUESTS_KEY: &str = "coop/nip4e/processed-requests"; const APP_KEYS_KEY: &str = "coop/nip4e/app-keys";
/// Read the stored credential as `(username, secret)` /// Read the stored credential as `(username, secret)`
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
@@ -94,12 +94,12 @@ fn write_named(cx: &App, key: &str, secret: &[u8]) -> Task<Result<()>> {
} }
} }
/// Read the log of handled key-sync request ids, as concatenated hex ids /// Load the NIP-4e app keys from storage, as raw secret bytes
pub fn read_processed_requests(cx: &App) -> Task<Result<Option<Vec<u8>>>> { pub fn read_app_keys(cx: &App) -> Task<Result<Option<Vec<u8>>>> {
read_named(cx, PROCESSED_REQUESTS_KEY) read_named(cx, APP_KEYS_KEY)
} }
/// Persist the log of handled key-sync request ids, as concatenated hex ids /// Persist the NIP-4e app keys, as raw secret bytes
pub fn write_processed_requests(cx: &App, ids: &[u8]) -> Task<Result<()>> { pub fn write_app_keys(cx: &App, secret: &[u8]) -> Task<Result<()>> {
write_named(cx, PROCESSED_REQUESTS_KEY, ids) write_named(cx, APP_KEYS_KEY, secret)
} }
+1 -14
View File
@@ -5,10 +5,6 @@ use nostr_sdk::prelude::*;
pub struct Announcement { pub struct Announcement {
/// The public key of the device that created this announcement. /// The public key of the device that created this announcement.
public_key: PublicKey, public_key: PublicKey,
/// The id of the announcement event, for ordering
event_id: EventId,
/// The creation time of the announcement event
created_at: Timestamp,
} }
impl Announcement { impl Announcement {
@@ -20,20 +16,11 @@ impl Announcement {
.and_then(|tag| tag.content()) .and_then(|tag| tag.content())
.and_then(|c| PublicKey::parse(c).ok())?; .and_then(|c| PublicKey::parse(c).ok())?;
Some(Self { Some(Self { public_key })
public_key,
event_id: event.id,
created_at: event.created_at,
})
} }
/// Returns the public key of the device that created this announcement. /// Returns the public key of the device that created this announcement.
pub fn public_key(&self) -> PublicKey { pub fn public_key(&self) -> PublicKey {
self.public_key self.public_key
} }
pub fn is_newer_than(&self, other: &Self) -> bool {
self.created_at > other.created_at
|| (self.created_at == other.created_at && self.event_id < other.event_id)
}
} }
-2
View File
@@ -30,7 +30,6 @@ pub enum IconName {
Search, Search,
Settings, Settings,
Settings2, Settings2,
Unlock,
Upload, Upload,
Warning, Warning,
} }
@@ -64,7 +63,6 @@ impl IconNamed for IconName {
Self::Search => "icons/search.svg", Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg", Self::Settings => "icons/settings.svg",
Self::Settings2 => "icons/settings2.svg", Self::Settings2 => "icons/settings2.svg",
Self::Unlock => "icons/unlock.svg",
Self::Upload => "icons/upload.svg", Self::Upload => "icons/upload.svg",
Self::Warning => "icons/warning.svg", Self::Warning => "icons/warning.svg",
} }
+2 -6
View File
@@ -9,11 +9,11 @@ ui = { path = "../ui" }
gpui-kit.workspace = true gpui-kit.workspace = true
common = { path = "../common" } common = { path = "../common" }
state = { path = "../state" } state = { path = "../state" }
nip29 = { path = "../nip29" }
nip29_ui = { path = "../nip29_ui" }
nip4e = { path = "../nip4e" } nip4e = { path = "../nip4e" }
chat = { path = "../chat" } chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" } chat_ui = { path = "../chat_ui" }
nip29 = { path = "../nip29" }
nip29_ui = { path = "../nip29_ui" }
settings = { path = "../settings" } settings = { path = "../settings" }
speech = { path = "../speech" } speech = { path = "../speech" }
person = { path = "../person" } person = { path = "../person" }
@@ -30,7 +30,3 @@ serde.workspace = true
log.workspace = true log.workspace = true
rand.workspace = true rand.workspace = true
smallvec.workspace = true smallvec.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
nip60 = { path = "../nip60" }
nip60_ui = { path = "../nip60_ui" }
+44 -88
View File
@@ -1,18 +1,15 @@
use anyhow::Error; use anyhow::Error;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Styled, Subscription, Task, Window, div, px, relative, Subscription, Task, Window, div, px, relative,
}; };
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_kit::component::form::{field, v_form};
use gpui_kit::component::input::{Input, InputEvent, InputState}; use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex}; use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex};
use instant::Duration; use instant::Duration;
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
use state::{NostrRegistry, StateEvent}; use state::{NostrRegistry, StateEvent};
use ui::IconName;
pub fn open(window: &mut Window, cx: &mut App) { pub fn open(window: &mut Window, cx: &mut App) {
let import = cx.new(|cx| ImportIdentity::new(window, cx)); let import = cx.new(|cx| ImportIdentity::new(window, cx));
@@ -35,6 +32,7 @@ pub fn open_unlock(window: &mut Window, cx: &mut App) {
this.close_button(false) this.close_button(false)
.overlay_closable(false) .overlay_closable(false)
.keyboard(false) .keyboard(false)
.title("Unlock your identity")
.child(import.clone()) .child(import.clone())
}); });
} }
@@ -76,15 +74,7 @@ impl ImportIdentity {
fn build(mode: Mode, window: &mut Window, cx: &mut Context<Self>) -> Self { fn build(mode: Mode, window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://")); let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://"));
let pass_input = cx.new(|cx| { let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let input = InputState::new(window, cx).masked(true);
if mode == Mode::Unlock {
input.placeholder("Passphrase to unlock your identity")
} else {
input
}
});
let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true)); let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let error = cx.new(|_| None); let error = cx.new(|_| None);
@@ -245,59 +235,15 @@ impl ImportIdentity {
Ok(()) Ok(())
})); }));
} }
fn render_unlock(&self, cx: &mut Context<Self>) -> Div {
v_flex()
.gap_4()
.child(
DialogHeader::new()
.child(DialogTitle::new().child("Unlock your identity"))
.child(
DialogDescription::new()
.child("Enter the passphrase used to encrypt this identity."),
),
)
.child(
v_form().child(
field()
.label("Passphrase")
.required(true)
.child(Input::new(&self.pass_input)),
),
)
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_sm()
.text_color(cx.theme().danger)
.child(error.clone()),
)
})
.child(
DialogFooter::new().child(
Button::new("unlock")
.primary()
.icon(IconName::Unlock)
.label("Unlock")
.loading(self.loading)
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.login(cx);
})),
),
)
}
} }
impl Render for ImportIdentity { impl Render for ImportIdentity {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.mode == Mode::Unlock {
return self.render_unlock(cx);
}
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages."; const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
const KEY_WARN: &str = "Coop encrypts your key with your passphrase and stores it on this device. You only need the passphrase to sign in next time."; const KEY_WARN: &str = "Coop encrypts your key with your passphrase and stores it on this device. You only need the passphrase to sign in next time.";
let is_unlock = self.mode == Mode::Unlock;
let value = self.key_input.read(cx).value().trim().to_string(); let value = self.key_input.read(cx).value().trim().to_string();
let require_password = value.starts_with("ncryptsec1"); let require_password = value.starts_with("ncryptsec1");
let require_encrypt = value.starts_with("nsec1"); let require_encrypt = value.starts_with("nsec1");
@@ -311,19 +257,25 @@ impl Render for ImportIdentity {
.child( .child(
v_flex() v_flex()
.gap_2() .gap_2()
.child( .when(!is_unlock, |this| {
v_flex()
.gap_1()
.text_color(cx.theme().muted_foreground)
.child("Use secret key or bunker connection")
.child(Input::new(&self.key_input)),
)
.when(require_password, |this| {
this.child( this.child(
v_flex() v_flex()
.gap_1() .gap_1()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child("Decrypt Password:") .child("Use secret key or bunker connection")
.child(Input::new(&self.key_input)),
)
})
.when(is_unlock || require_password, |this| {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().muted_foreground)
.child(if is_unlock {
"Passphrase to unlock your identity:"
} else {
"Decrypt Password:"
})
.child(Input::new(&self.pass_input)), .child(Input::new(&self.pass_input)),
) )
}) })
@@ -371,7 +323,7 @@ impl Render for ImportIdentity {
.child({ .child({
let content = v_flex().gap_1().child( let content = v_flex().gap_1().child(
Button::new("login") Button::new("login")
.label("Continue") .label(if is_unlock { "Unlock" } else { "Continue" })
.primary() .primary()
.loading(self.loading) .loading(self.loading)
.disabled(self.loading) .disabled(self.loading)
@@ -381,26 +333,30 @@ impl Render for ImportIdentity {
); );
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
let content = content.child( let content = content.when(!is_unlock, |this| {
Button::new("proxy") this.child(
.label("Connect via Web Extension") Button::new("proxy")
.ghost() .label("Connect via Web Extension")
.disabled(self.loading) .ghost()
.on_click(cx.listener(move |this, _ev, _window, cx| { .disabled(self.loading)
this.proxy(cx); .on_click(cx.listener(move |this, _ev, _window, cx| {
})), this.proxy(cx);
); })),
)
});
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
let content = content.child( let content = content.when(!is_unlock, |this| {
Button::new("extension") this.child(
.label("Continue with Browser Extension") Button::new("extension")
.ghost() .label("Continue with Browser Extension")
.disabled(self.loading) .ghost()
.on_click(cx.listener(move |this, _ev, _window, cx| { .disabled(self.loading)
this.extension(cx); .on_click(cx.listener(move |this, _ev, _window, cx| {
})), this.extension(cx);
); })),
)
});
content content
}) })
+3 -3
View File
@@ -1,11 +1,11 @@
use std::rc::Rc;
use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px};
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputState}; use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::notification::Notification; use gpui_kit::component::notification::Notification;
use gpui_kit::component::{WindowExt, h_flex, v_flex}; use gpui_kit::component::{WindowExt, h_flex, v_flex};
use std::rc::Rc;
use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px};
use nip29::{GroupReference, GroupsRegistry}; use nip29::{GroupReference, GroupsRegistry};
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>; type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
+2 -20
View File
@@ -22,7 +22,7 @@ use gpui_kit::component::progress::Progress;
use gpui_kit::component::separator::Separator; use gpui_kit::component::separator::Separator;
use gpui_kit::component::switch::Switch; use gpui_kit::component::switch::Switch;
use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex}; use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex};
use settings::{AppSettings, AppearanceMode, NIP4E_ROTATION_DEFAULT_DAYS}; use settings::{AppSettings, AppearanceMode};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use speech::model::{self as speech_model}; use speech::model::{self as speech_model};
use ui::{settings_block, settings_row}; use ui::{settings_block, settings_row};
@@ -337,7 +337,6 @@ impl Render for Preferences {
"Corner radius for large elements like dialogs. Empty follows the OS theme."; "Corner radius for large elements like dialogs. Empty follows the OS theme.";
const FOCUS_RING: &str = "Draw a ring around focused controls."; const FOCUS_RING: &str = "Draw a ring around focused controls.";
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages."; const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
const NIP4E_ROTATION: &str = "Automatically rotate the encryption key every 30 days, keeping the previous key for messages still sealed to it.";
const BACKGROUND: &str = "Show an image behind chat and group panels."; const BACKGROUND: &str = "Show an image behind chat and group panels.";
const MEDIA: &str = "Server for media attachments. Only blossom services are supported."; const MEDIA: &str = "Server for media attachments. Only blossom services are supported.";
@@ -345,7 +344,6 @@ impl Render for Preferences {
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx); let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx); let hide_avatar = AppSettings::get_hide_avatar(cx);
let nip4e = AppSettings::get_nip4e(cx); let nip4e = AppSettings::get_nip4e(cx);
let nip4e_rotation_days = AppSettings::get_nip4e_rotation_days(cx);
let appearance_mode = AppSettings::get_appearance_mode(cx); let appearance_mode = AppSettings::get_appearance_mode(cx);
let theme = AppSettings::get_theme(cx); let theme = AppSettings::get_theme(cx);
let panel_background = AppSettings::get_panel_background(cx); let panel_background = AppSettings::get_panel_background(cx);
@@ -357,7 +355,7 @@ impl Render for Preferences {
}; };
v_flex() v_flex()
.my_2() .mt_2()
.gap_4() .gap_4()
.w_full() .w_full()
.child( .child(
@@ -509,22 +507,6 @@ impl Render for Preferences {
}), }),
cx, cx,
)) ))
.child(settings_row(
"Rotate Encryption Key",
NIP4E_ROTATION,
Switch::new("nip4e_rotation")
.checked(nip4e_rotation_days.is_some())
.on_click(move |_, _window, cx| {
let enabled = AppSettings::get_nip4e_rotation_days(cx).is_some();
let days = if enabled {
None
} else {
Some(NIP4E_ROTATION_DEFAULT_DAYS)
};
AppSettings::update_nip4e_rotation_days(days, cx);
}),
cx,
))
.child(Separator::horizontal()) .child(Separator::horizontal())
.child(self.render_speech(cx)) .child(self.render_speech(cx))
.child(self.render_dictation_shortcut(window, cx)) .child(self.render_dictation_shortcut(window, cx))
+4 -36
View File
@@ -61,8 +61,6 @@ enum Command {
ShowContactList, ShowContactList,
ShowInbox, ShowInbox,
ShowBrowse, ShowBrowse,
#[cfg(not(target_arch = "wasm32"))]
ShowWallet,
NewChat, NewChat,
NewGroup, NewGroup,
JoinGroup, JoinGroup,
@@ -93,12 +91,11 @@ impl Workspace {
let workspace = cx.entity().downgrade(); let workspace = cx.entity().downgrade();
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| { if AppSettings::get_appearance_mode(cx) == AppearanceMode::System {
if AppSettings::get_appearance_mode(cx) == AppearanceMode::System { subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
Theme::sync_system_appearance(Some(window), cx); Theme::sync_system_appearance(Some(window), cx);
AppSettings::apply_theme_style(cx); }));
} }
}));
subscriptions.push( subscriptions.push(
// Subscribe to the nostr events // Subscribe to the nostr events
@@ -198,30 +195,6 @@ impl Workspace {
}), }),
); );
#[cfg(not(target_arch = "wasm32"))]
{
let wallet = ::nip60::WalletRegistry::global(cx);
subscriptions.push(cx.subscribe_in(
&wallet,
window,
|_this, _wallet, event, window, cx| match event {
::nip60::WalletEvent::Error(error) => {
window.push_notification(Notification::error(error.clone()), cx);
}
::nip60::WalletEvent::TokenCreated(_) => {
window.push_notification(
Notification::new()
.message("Cashu token created, copy it from the wallet panel")
.with_type(NotificationType::Success),
cx,
);
}
::nip60::WalletEvent::Updated => {}
},
));
}
App::on_action(cx, { App::on_action(cx, {
let workspace = workspace.clone(); let workspace = workspace.clone();
move |action, cx| { move |action, cx| {
@@ -333,11 +306,6 @@ impl Workspace {
let panel = browse::init(self.dock.downgrade(), window, cx); let panel = browse::init(self.dock.downgrade(), window, cx);
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx); self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
} }
#[cfg(not(target_arch = "wasm32"))]
Command::ShowWallet => {
let panel = nip60_ui::wallet::init(window, cx);
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
}
Command::NewChat => { Command::NewChat => {
new_chat::open(self.dock.downgrade(), window, cx); new_chat::open(self.dock.downgrade(), window, cx);
} }
+4 -103
View File
@@ -1,25 +1,18 @@
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use auto_update::AutoUpdater; use auto_update::AutoUpdater;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{
Anchor, App, Context, Div, InteractiveElement, IntoElement, ParentElement, SharedString,
Stateful, Styled, Window, div, px,
};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use gpui::{AnyElement, Entity}; use gpui::{AnyElement, Entity, IntoElement};
use gpui_kit::base::StyledExt as _; use gpui::{
App, Context, Div, InteractiveElement, ParentElement, Stateful, Styled, Window, div, px,
};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use gpui_kit::component::Disableable; use gpui_kit::component::Disableable;
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem}; use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::popover::Popover;
use gpui_kit::component::separator::Separator;
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex}; use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::shorten_pubkey;
use settings::AppSettings;
use state::{CLIENT_NAME, NostrRegistry}; use state::{CLIENT_NAME, NostrRegistry};
use ui::{Avatar, IconName}; use ui::{Avatar, IconName};
@@ -38,10 +31,6 @@ impl Sidebar {
let updater = AutoUpdater::try_global(cx); let updater = AutoUpdater::try_global(cx);
let profile = self.directory.read(cx).person(current_user, cx); let profile = self.directory.read(cx).person(current_user, cx);
let avatar = profile.avatar(); let avatar = profile.avatar();
let encryption_key = profile
.announcement()
.map(|announcement| announcement.public_key());
let encryption_enabled = AppSettings::get_nip4e(cx);
h_flex() h_flex()
.id("sidebar-user") .id("sidebar-user")
@@ -82,11 +71,6 @@ impl Sidebar {
}); });
actions actions
.when(encryption_enabled, |actions| {
actions.when_some(encryption_key, |actions, encryption_key| {
actions.child(self.render_encryption(encryption_key))
})
})
.child( .child(
Button::new("sidebar-search") Button::new("sidebar-search")
.icon(IconName::Search) .icon(IconName::Search)
@@ -127,89 +111,6 @@ impl Sidebar {
}) })
} }
/// The encryption key popover: public key with copy, backup and reset
fn render_encryption(&self, encryption_key: PublicKey) -> impl IntoElement {
Popover::new("sidebar-encryption")
.anchor(Anchor::TopRight)
.trigger(
Button::new("encryption-key")
.icon(IconName::Lock)
.tooltip("Encryption Key")
.ghost()
.small(),
)
.content(move |_, _, cx| {
let popover = cx.entity();
let Ok(npub) = encryption_key.to_bech32();
v_flex()
.w_64()
.gap_2()
.text_sm()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Public key"),
)
.child(
h_flex()
.items_center()
.gap_1()
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(shorten_pubkey(encryption_key, 12))),
)
.child(
Clipboard::new("encryption-key-copy")
.tooltip("Copy public key")
.value(npub),
),
)
.child(Separator::horizontal())
.child(
h_flex()
.justify_end()
.gap_2()
.child(
Button::new("encryption-backup")
.label("Backup")
.outline()
.small()
.on_click({
let popover = popover.clone();
move |_, window, cx| {
popover
.update(cx, |state, cx| state.dismiss(window, cx));
window.dispatch_action(
Box::new(Command::BackupEncryption),
cx,
);
}
}),
)
.child(
Button::new("encryption-reset")
.label("Reset")
.ghost()
.small()
.on_click(move |_, window, cx| {
popover.update(cx, |state, cx| state.dismiss(window, cx));
window.dispatch_action(
Box::new(Command::ResetEncryption),
cx,
);
}),
),
)
})
}
pub(super) fn render_footer(&self, loading: bool, _cx: &mut Context<Self>) -> Div { pub(super) fn render_footer(&self, loading: bool, _cx: &mut Context<Self>) -> Div {
v_flex() v_flex()
.p_2() .p_2()
-14
View File
@@ -43,19 +43,6 @@ impl Sidebar {
// Whether to show the create button for the contact picker // Whether to show the create button for the contact picker
let create_button = contacts_open && !self.selected_contacts.is_empty(); let create_button = contacts_open && !self.selected_contacts.is_empty();
#[cfg(not(target_arch = "wasm32"))]
let wallet_row = Some(
Nav::new("wallet")
.prefix(Icon::new(gpui_kit::assets::IconName::Wallet).small())
.label("Wallet")
.text_sm()
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(crate::Command::ShowWallet), cx)
}),
);
#[cfg(target_arch = "wasm32")]
let wallet_row: Option<Nav> = None;
let (list_id, title) = if contacts_open { let (list_id, title) = if contacts_open {
("sidebar-contacts", Some("Contacts")) ("sidebar-contacts", Some("Contacts"))
} else if requests_open { } else if requests_open {
@@ -88,7 +75,6 @@ impl Sidebar {
window.dispatch_action(Box::new(Command::ShowBrowse), cx) window.dispatch_action(Box::new(Command::ShowBrowse), cx)
}), }),
) )
.when_some(wallet_row, |this, row| this.child(row))
.child( .child(
Nav::new("reqs") Nav::new("reqs")
.prefix(Icon::new(IconName::Invite).small()) .prefix(Icon::new(IconName::Invite).small())
+1 -9
View File
@@ -21,7 +21,6 @@ use nostr_sdk::prelude::*;
use person::{Directory, PersonRegistry}; use person::{Directory, PersonRegistry};
pub(crate) use rows::open_room; pub(crate) use rows::open_room;
use search::SearchResult; use search::SearchResult;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry, StateEvent}; use state::{FIND_DELAY, NostrRegistry, StateEvent};
use ui::title_bar_drag_handlers; use ui::title_bar_drag_handlers;
@@ -105,8 +104,6 @@ impl Sidebar {
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let groups = GroupsRegistry::global(cx); let groups = GroupsRegistry::global(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let settings = AppSettings::global(cx);
let directory = Directory::new(cx);
let find_input = cx.new(|cx| { let find_input = cx.new(|cx| {
InputState::new(window, cx) InputState::new(window, cx)
@@ -209,11 +206,6 @@ impl Sidebar {
}, },
)); ));
// The user header reads settings and the tracked profiles on render, so
// the encryption button reacts to toggles and announcement arrivals
subscriptions.push(cx.observe(&settings, |_, _, cx| cx.notify()));
subscriptions.push(cx.observe(&directory, |_, _, cx| cx.notify()));
tasks.push(cx.spawn(async move |this, cx| { tasks.push(cx.spawn(async move |this, cx| {
let timer = Duration::from_millis(PROGRESS_TICK_MS); let timer = Duration::from_millis(PROGRESS_TICK_MS);
loop { loop {
@@ -237,7 +229,7 @@ impl Sidebar {
chats_open: None, chats_open: None,
contacts_open: false, contacts_open: false,
contacts: None, contacts: None,
directory, directory: Directory::new(cx),
selected_contacts: HashSet::new(), selected_contacts: HashSet::new(),
requests_open: false, requests_open: false,
new_requests: false, new_requests: false,
-1
View File
@@ -37,7 +37,6 @@ workspace = { path = "../crates/workspace" }
ui = { path = "../crates/ui" } ui = { path = "../crates/ui" }
state = { path = "../crates/state" } state = { path = "../crates/state" }
nip4e = { path = "../crates/nip4e" } nip4e = { path = "../crates/nip4e" }
nip60 = { path = "../crates/nip60" }
chat = { path = "../crates/chat" } chat = { path = "../crates/chat" }
chat_ui = { path = "../crates/chat_ui" } chat_ui = { path = "../crates/chat_ui" }
nip29 = { path = "../crates/nip29" } nip29 = { path = "../crates/nip29" }
+14 -12
View File
@@ -72,13 +72,14 @@ fn main() {
let appearance = settings::persisted_appearance_mode(); let appearance = settings::persisted_appearance_mode();
let font_size = settings::persisted_theme().font_size; let font_size = settings::persisted_theme().font_size;
match apply_native_theme(appearance, font_size, cx) {
if let Err(error) = apply_native_theme(appearance, font_size, cx) { Ok(()) => start_theme_watcher(appearance, cx),
log::warn!("native theme unavailable ({error})"); Err(error) => {
apply_appearance(appearance, cx); log::warn!("native theme unavailable ({error})");
AppSettings::apply_theme_style(cx); apply_appearance(appearance, cx);
AppSettings::apply_theme_style(cx);
}
} }
start_theme_watcher(cx);
// Initialize the nostr client // Initialize the nostr client
state::init(cx, cli_key); state::init(cx, cli_key);
@@ -91,11 +92,6 @@ fn main() {
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md // NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
nip4e::init(cx); nip4e::init(cx);
// Initialize cashu wallet registry
//
// NIP-60: https://github.com/nostr-protocol/nips/blob/master/60.md
nip60::init(cx);
// Initialize app registry // Initialize app registry
chat::init(cx); chat::init(cx);
@@ -217,12 +213,14 @@ fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
/// Holds the watcher subscription so it lives as long as the app. /// Holds the watcher subscription so it lives as long as the app.
struct ThemeWatcher { struct ThemeWatcher {
_subscription: ThemeSubscription, _subscription: ThemeSubscription,
/// The appearance mode the observer in `workspace` was registered with.
appearance: std::cell::Cell<AppearanceMode>,
} }
impl Global for ThemeWatcher {} impl Global for ThemeWatcher {}
/// Start a background task to watch for OS theme changes and re-apply the native theme. /// Start a background task to watch for OS theme changes and re-apply the native theme.
fn start_theme_watcher(cx: &mut App) { fn start_theme_watcher(initial_appearance: AppearanceMode, cx: &mut App) {
let (sender, receiver) = flume::unbounded(); let (sender, receiver) = flume::unbounded();
match native_theme::watch::on_theme_change(move |event| { match native_theme::watch::on_theme_change(move |event| {
if let Err(error) = sender.send(event) { if let Err(error) = sender.send(event) {
@@ -232,6 +230,7 @@ fn start_theme_watcher(cx: &mut App) {
Ok(subscription) => { Ok(subscription) => {
cx.set_global(ThemeWatcher { cx.set_global(ThemeWatcher {
_subscription: subscription, _subscription: subscription,
appearance: std::cell::Cell::new(initial_appearance),
}); });
cx.spawn(async move |cx| { cx.spawn(async move |cx| {
while receiver.recv_async().await.is_ok() { while receiver.recv_async().await.is_ok() {
@@ -239,6 +238,9 @@ fn start_theme_watcher(cx: &mut App) {
cx.update(|cx| { cx.update(|cx| {
let appearance = AppSettings::get_appearance_mode(cx); let appearance = AppSettings::get_appearance_mode(cx);
let font_size = AppSettings::get_theme(cx).font_size; let font_size = AppSettings::get_theme(cx).font_size;
if let Some(watcher) = cx.try_global::<ThemeWatcher>() {
watcher.appearance.set(appearance);
}
if let Err(error) = apply_native_theme(appearance, font_size, cx) { if let Err(error) = apply_native_theme(appearance, font_size, cx) {
log::warn!("failed to apply updated OS theme ({error})"); log::warn!("failed to apply updated OS theme ({error})");
} }
-387
View File
@@ -1,387 +0,0 @@
# NIP-60: cashu wallet — implementation plan
Spec: https://github.com/nostr-protocol/nips/blob/master/60.md
CDK: https://github.com/cashubtc/cdk (crates `cdk`, `cdk-redb`, from git main)
NIP-61: https://github.com/nostr-protocol/nips/blob/master/61.md
NIP-60 stores a user's cashu wallet state on relays: the wallet config
(kind `17375`), its unspent proofs (kind `7375`), and an optional spending
history (kind `7376`). All content is NIP-44 encrypted to self. This plan
implements it inside coop using the CDK for all cashu/mint operations and
the app's existing nostr client/signer for events.
## Analysis
| Kind | Replaceable | Content (NIP-44 to self) | Purpose |
| ------ | ----------- | ----------------------------------------------- | -------------------------------- |
| 17375 | yes `[[privkey, hex], [mint, url], ...]` | wallet config: P2PK key + mints |
| 7375 | no `{mint, unit, proofs[], del[]}` | unspent proofs (per mint) |
| 7376 | no `[[direction], [amount], [unit], [e, ...]]` | spend/receive history (optional) |
| 7374 | no `quote-id` + `mint`/`expiration` tags | pending mint quote (optional) |
| 10019 | yes empty, `relay` tags | wallet relay list |
Key mechanics:
- Wallet state transitions are rollover: spending part of a token event
publishes a new `7375` with the unspent proofs + `del: [old-event-id]`,
then NIP-09 deletes the old event (the delete MUST carry `["k", "7375"]`).
- The `privkey` in `17375` is a wallet-only key (never the nostr identity
key). It exists to unlock P2PK-locked ecash, i.e. redeeming NIP-61
nutzaps.
- NIP-60 itself does not move money to/from others. Receiving ecash from
peers is NIP-61 (nutzaps): public `kind:9321` events published by the
sender, p-tagging the recipient's nostr identity pubkey, carrying
P2PK-locked proofs (`proof` tags) on a trusted mint (`u` tag). Redemption
is `receive_proofs` with the wallet signing key + a `7376` whose
unencrypted tags mark the nutzap `redeemed` and carry the sender pubkey.
- NIP-61 discovery runs through a `kind:10019` informational event
(relays, mints, wallet pubkey) published alongside the wallet event.
- History (7376) and quote events (7374) are optional; the spec recommends
local state for quotes. We publish 7376, skip 7374 (cdk keeps quotes in
its local DB).
## How it fits coop
Follows the existing crate patterns exactly; no new architecture.
- One protocol crate: `crates/nip60`, mirroring `nip4e`/`nip29` — a
registry entity set as GPUI global by `init(cx)`, `EventEmitter` for
UI updates, `SmallVec<Task>` + `SmallVec<Subscription>` fields, flume
signal channel for background results, reset/reload on
`NostrRegistry::signer_changed` (see `crates/nip29/src/lib.rs`).
- Desktop-only: crate-level `#![cfg(not(target_arch = "wasm32"))]` like
`crates/auto_update/src/lib.rs`. Wallet UI is only wired on desktop.
- Tokio bridge: all cdk calls run through `gpui_tokio::Tokio::spawn(cx, ...)`
(runtime is already initialized by `state::init`). The `cdk-nostr`
inbox listener also runs on that runtime.
- Signer-agnostic encryption: the signer may be a bunker/extension, so we
never use `nostr::nips::nip60` prepare helpers (they need an owned
`SecretKey`).
We build payloads as plain JSON, encrypt with
`signer.nip44_encrypt(self_pubkey, json)` and sign with
`EventBuilder::new(kind, content).finalize_async(signer)` — the same
pattern as `crates/nip4e/src/storage.rs`. Parsing is decrypt-then-parse:
`TokenEvent` derives `Deserialize`; `17375` content is a trivial
`Vec<Vec<String>>` parse.
- Existing facts we build on:
- rust-nostr (git, already a dependency) ships `nostr::nip60` with the
event data types (`WalletEvent`, `TokenEvent`, `SpendingHistory`,
`QuoteEvent`, `CashuProof`) and kinds
(`Kind::CashuWallet` = 17375, `CashuWalletUnspentProof` = 7375,
`CashuWalletSpendingHistory` = 7376, `CashuWalletQuote` = 7374).
It is behind the `nip60` feature, which coop does not enable yet.
- `state::NostrRegistry` gives the `Client` (LMDB cache + bootstrap
relays + gossip) and the `UniversalSigner`.
- Local file storage precedent: `NostrLmdb::open(config_dir().join("nostr"))`
in `crates/state/src/lib.rs`; cdk's sqlite store goes to
`config_dir().join("cashu")`.
## Dependencies
Workspace `Cargo.toml` (final state after the phase 0 spike):
```toml
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip60", "nip98" ] }
# Cashu (NIP-60). From git: the published 0.18.x line targets nostr 0.44 and
# cannot unify with the git rust-nostr used here, while main targets 0.45.x
cdk = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
cdk-redb = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
cdk-nostr = { git = "https://github.com/cashubtc/cdk", default-features = false }
[patch.crates-io]
nostr = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
```
Spike findings (all resolved and verified with `cargo check`):
- The cdk family hard-pins `cdk-common` with `=` per release, so all crates
must come from one release train. crates.io 0.18.3 pins nostr 0.44 and
cannot coexist with coop's git nostr 0.45.x; the git main line pins
nostr ^0.45.3, which the `[patch.crates-io]` block redirects onto the
same git rust-nostr checkout. The lockfile ends with exactly one `nostr`
and one `nostr-sdk`, proven by compile-time type-identity checks between
`nostr` and `cdk_nostr::nostr`.
- Storage is `cdk-redb` (`WalletRedbDatabase::new(&Path)`, pure Rust), not
`cdk-sqlite`: published cdk-sqlite pins rusqlite ^0.31, whose
`libsqlite3-sys` clashes at the `links` level with the `nostr-sqlite`
wasm dependency of the `state` crate.
- rust-nostr 0.45.x has no root re-exports: the module is
`nostr::nips::nip60`, and cdk-nostr re-exports the SDK as
`cdk_nostr::nostr` / `cdk_nostr::nostr_sdk`.
- The `nip60` crate exists (`crates/nip60`) with a compile-only spike in
`lib.rs` and a no-op `init(cx)` wired into `desktop/src/main.rs`; both
are replaced in phase 1.
## Design
### Key material
- On first `setup(mint_url)` (explicit user action, no auto-creation):
generate `SecretKey::generate()` as the wallet key, publish `17375`
containing it + the mint list (encrypted to self, signed by the user).
- The cdk seed is derived deterministically from the wallet key
(`HKDF-SHA256(wallet_privkey, info = "coop cashu seed")` -> `[u8; 64]`).
No extra local storage, and a fresh device recovers the full wallet from
the relay state alone. Tradeoff: the derivation is coop-specific (NIP-60
does not standardize seed derivation), so it does not restore into other
apps — acceptable, see open questions.
- Proof-level secrets/counters live in cdk's sqlite store; NUT-13 restore
from mints works because the seed is deterministic.
### Local state
- cdk redb store at `config_dir().join("cashu")` (one `WalletDatabase`
shared by all mint wallets).
- `WalletRegistry` holds: parsed `17375` (`WalletEvent` + event id), the
active `7375` events per mint (event id + `TokenEvent`), cdk `Wallet`
per mint (`CurrencyUnit::Sat` only in v1), and the subscription handle.
### Relay sync
- One subscription: `authors: [me], kinds: [17375, 7375, 7376]` plus
kind `5` with `k: 7375`, on the existing client (bootstrap relays).
Notifications flow through the nip29-style flume channel.
- Published relays: the `10019` nutzap info event advertises the user's
NIP-65 relay list (queried from the local database), never the bootstrap
relays; subscriptions and publishes ride the SDK's NIP-65 handling.
Reading `10019` from other clients is skipped in v1 (coop always uses
its own relay set).
### Operations
Each op runs on tokio and ends with the same `sync` tail:
read unspent proofs from cdk -> publish one `7375` per changed mint
(with `del` set to the previous event ids) -> NIP-09 delete old events with
a `k:7375` tag -> publish `7376` history -> update registry state and emit.
- `setup(mint_url)`: create wallet key + seed, publish `17375`, build cdk
`Wallet`, open subscription.
- `create_mint_quote(amount)`: a job in the queue — `wallet.mint_quote` ->
invoice recorded in `mint_quotes()` for the UI; poll /
`claim_mint_quote(id)`, then `wallet.mint(quote_id)` -> sync.
On load, `mint_unissued_quotes` claims quotes paid elsewhere.
- `melt(invoice)`: `wallet.melt` -> sync (change proofs roll over).
- `send_token(amount)`: `wallet.send` -> return token string for the user
to share -> sync. (No in-chat token delivery in v1.)
- `receive_token(token)`: `wallet.receive` -> sync.
- Redeem nutzap (NIP-61): verify the proofs are P2PK-locked to
the wallet pubkey, then `wallet.receive_proofs(proofs, ReceiveOptions {
p2pk_signing_keys: [wallet_key] })` -> `7376` with unencrypted
`e <nutzap-id> "" redeemed` tag -> sync.
- Remote `7375` from another device: import its unspent proofs into the
cdk store as the new local truth (cdk proof insertion + state check via
`check_proofs_spent`); superseded `7375`s reconcile through `del`/NIP-09.
- On every reconcile: `recover_incomplete_sagas` and
`mint_unissued_quotes` (required by cdk to clear interrupted operations),
then converge the unspent set from `7375` minus deletes.
### Crate layout (3 files, kept small on purpose)
- `lib.rs` — `init`, `WalletRegistry`, `WalletEvent` (`Updated`, `Error`),
subscription/notification handling, `reset` on signer change.
- `keys.rs` — wallet key/seed derivation, `17375` build/parse via signer,
`7375`/deletion/`7376` event builders, the `History` payload.
- `ops.rs` — cdk operations + the shared `sync` tail (rollover, deletes,
history), NIP-61 redemption.
Wiring: `nip60::init(cx)` in `desktop/src/main.rs` after `nip4e::init(cx)`;
`nip60 = { path = "../crates/nip60" }` added to `desktop/Cargo.toml` (and
the UI crate when Phase 3 lands).
## Phases
### Phase 0 — dependency spike (done)
Outcome recorded in the Dependencies section: cdk family on git main,
`[patch.crates-io]` unification, cdk-redb storage, `nip60` crate skeleton
wired into desktop. `cargo check -p nip60` and the full desktop check pass.
The remaining spike code in `crates/nip60/src/lib.rs` is deleted in
phase 1.
### Phase 1 — bootstrap + load + balance (done)
Implemented in `crates/nip60` (`lib.rs`, `keys.rs`):
- `WalletRegistry` follows the nip29/nip4e pattern: global entity, signer
reset, flume notification listener, task + subscription SmallVecs.
- `keys.rs`: wallet key generation, HKDF seed derivation, `17375`
encrypt/parse through the signer (works with bunkers), unit tests.
- Load: subscribe + query cache for 17375/7375/7376 and kind 5 `k:7375`,
decrypt via signer, prune deleted (`k:7375` deletes) and superseded
(`del` chains) token events, expose `has_wallet()`/`balance()`.
- cdk: redb store per wallet pubkey under `config_dir()/cashu`, one `Wallet`
per mint (`sat`), unspent proofs imported/diffed against the store on
every sync, `recover_incomplete_sagas` + `mint_unissued_quotes` fire on
first store open.
- `setup(mint)` creates the wallet (first mint) or adds a mint, publishing
`17375` and saving it to the local cache.
Deferred to later phases: token rollover publishing (phase 2), remote
proof validation at the mint (phase 2 reconciliation), replacing proofs
reserved by local in-flight operations (phase 2 guard).
### Phase 2 — moving money (done)
Implemented in `crates/nip60/src/ops.rs` plus registry wiring:
- All store mutations go through a serialized job queue
(`schedule_job`): one job at a time on the background executor,
snapshots taken at schedule time, one queued job slot. This keeps ops
and relay-driven imports from interleaving on the store, which a loose
sync tail would not guarantee.
- Jobs: `Reconcile` (claim paid quotes via `mint_unissued_quotes`, then
converge each mint — publish the store's unspent set when it diverges
from the relay view, and import relay-only proofs into the store),
`ClaimQuote`, `Melt`, `SendToken`, `Receive`.
- The sync tail (`sync_token_events`) publishes a fresh `7375` with the
store's unspent proofs, NIP-09-deletes the previous token events with
the required `k: 7375` tag, and publishes a `7376` history event with
`created`/`destroyed` markers.
- Registry API: `create_mint_quote(amount)` (invoice stored in
`mint_quotes()`, UI polls `claim_mint_quote(id)`), `melt(invoice)`,
`send_token(amount)` (token delivered via `WalletEvent::TokenCreated`),
`receive_token(token)` (mint resolved from the token).
- Claim-on-load moved from a fire-and-forget task into `Reconcile`:
the fire-and-forget variant raced the import pass, which could wipe
freshly claimed proofs from the store before they were published.
- History amounts: mint/receive record what was received; melt records
invoice amount + fee (`FinalizedMelt::total_amount`); send records the
sent amount.
Deferred: NUT-20/quote events (`7374`, per spec local state is fine),
sending tokens in-chat (v1 copies the token string).
### Phase 3 — NIP-61 redemption (done)
Spec note: NIP-61 changed since this plan was first written — nutzaps are
now public `kind:9321` events p-tagging the recipient's nostr identity
pubkey (the old draft's `kind:938` + gift-wrap shape is gone). So
`cdk_nostr::NostrInbox` (a NIP-17 gift-wrap listener) is not the right
tool; the dependency was dropped and reception rides the app's existing
nostr client.
Implemented:
- `setup` also publishes a `kind:10019` nutzap info event (bootstrap
relays, wallet mints, wallet pubkey) so other clients can nutzap us.
- A second subscription (`kinds: [9321], #p: me`) feeds nutzaps through
the same notification listener; the cache load scans stored `9321`s
too, so unredeemed nutzaps survive restarts.
- `absorb_nutzap` parses `proof`/`u` tags, resolves the mint against the
open wallets, verifies every proof is P2PK-locked to the wallet pubkey
(NUT-10 spending conditions), and enqueues `Job::RedeemNutzap` through
the serialized job queue.
- The job redeems via `wallet.receive_proofs` with the wallet key in
`p2pk_signing_keys`, then syncs with a `7376` history event whose
unencrypted tags mark the nutzap `redeemed` and tag the sender.
- Already-redeemed nutzaps (from cached `7376` tags) and ones with a job
in flight are skipped; failed redemptions are retried on relay echo.
### Audit (post phase 3) — done
Backend review before Phase 4: keep it simple, no premature edge-case
handling, struct/impl blocks over standalone functions. Applied:
- Code shape: `keys::WalletData` carries generate/derive_seed/decrypt/
to_event as an impl block; `ops::Job::run` and the `JobCtx` helpers
(`wallet`, `previous_ids`, `sync_token_events`, `reconcile`) are impl
methods; `LoadedState::load`, `WalletRegistry::open_store` and the
filters are associated functions; nutzap tag parsing moved into a
`Nutzap` struct (dropped the speculative `mint` tag fallback for the
current `u` tag). `History` moved to `keys.rs` so `build_history_event`
takes one struct instead of eight arguments.
- Spec gap: the sync tail never set `TokenEvent.del` — new `7375`s now
carry the previous event ids, so superseded tokens stay pruned even if
a NIP-09 delete is dropped by a relay.
- Bug: `start_job` errored "Wallet is not ready" for the auto `Reconcile`
scheduled before the redb store finished opening. Jobs scheduled while
the store is opening now queue and run once `ensure_wallets` completes;
`apply_loaded` no longer schedules a redundant `Reconcile`.
- Bug: a late-landing stale wallet decrypt could overwrite a newer
`17375`; `absorb_wallet` now re-checks `created_at` when applying.
- Consistency: an unreadable live token event logs a warning instead of
surfacing an error event (same as the load path); nutzaps arriving with
no wallet set up are dropped (a fresh setup would generate a different
key, so they were unredeemable anyway).
- Invariant: quote creation moved into the serialized job queue
(`Job::CreateQuote` / `Outcome::QuoteCreated`) — `mint_quote` writes to
the cdk store, so it must not interleave with running jobs. This also
collapsed the mint-resolution boilerplate shared by quote/melt/send
into `primary_mint`, and `MintQuoteEntry.mint` is now a `cdk::MintUrl`.
- Reconcile now calls `recover_incomplete_sagas` (the plan promised it;
it was missing), before `mint_unissued_quotes`. It is best-effort: the
cdk-redb fresh-store init does not create the `wallet_sagas` table
(upstream bug), so the first recovery attempts fail with "table does
not exist" until a saga is written — logged, never fatal.
Not changed, deliberately: per-outcome `Outcome` variants, the bounded
notification dedup set, the single queued Reconcile collapse, and the
remaining small conversion helpers in `ops.rs` (`cashu_proof`,
`proof_info`, `nostr_mint`) — they are simple, and further abstraction
would be over-engineering.
### Phase 4 — UI (done)
Implemented in a new `crates/nip60_ui` (crate-level wasm gate, so it
compiles to nothing on web; consumers list it under
`[target.'cfg(not(target_arch = "wasm32"))'.dependencies]`):
- Backend additions: `WalletRegistry::history()` (`HistoryEntry` list,
newest first, from live + cached `7376`s), and `send_nutzap(recipient,
amount, target, comment)` — `Job::SendNutzap` fetches the recipient's
`10019` from the local database (falling back to `stream_events`),
picks the first shared mint, P2PK-locks a `prepare_send` to their
wallet pubkey, publishes a public `9321` (`p`/`u`/`unit`/`proof` tags,
optional `e`), then runs the usual sync tail with an out-history.
- `wallet.rs` — `WalletPanel` in the left dock: balance, setup/add-mint,
send-token (token shown for copy), receive (invoice per quote with
copy + claim, paste-token redeem), history list. Subscribes to
`WalletEvent`; errors surface as notifications here and, via a
workspace-level subscription, even when the panel is closed.
- `lib.rs` — `open_nutzap` dialog (amount prompt) reused by chat.
- Sidebar: "Wallet" row under Browse (gated off on wasm), dispatching
`Command::ShowWallet` -> dock the panel.
- `chat_ui`: a coins button in the composer opens the nutzap dialog for
the room's peer; group rooms are declined with a warning (v1 is
direct-chat only).
- `nip29_ui`: "Send Nutzap" context-menu item on messages, nutzapping
the author with an `e` tag on the message.
Not rendered yet: nutzaps/tokens do not appear in the chat timeline; the
wallet panel history is the only transaction view.
## Tests
- `keys.rs` round-trips: encrypt/decrypt `17375` payloads against the
rust-nostr reference types (mirrors `nostr::nip60` unit tests).
- Rollover logic: given prior token events + a new unspent set, assert the
produced `7375`/delete/`7376` trio (pure functions, no network).
- Integration (optional, behind `#[ignore]`): testnut mint mint -> melt
cycle against cdk's memory store.
## Open questions / tradeoffs
- Seed derivation is coop-specific; cross-app portability of proof
restores is unsolved by NIP-60 generally. Revisit if a derivation
convention lands in the NIP or cdk.
- v1 is single-unit (`sat`) and effectively single-mint in the UI, even
though the event format and registry support many mints.
- Concurrent spends from two devices double-spend; recovery is the spec's
Appendix-1 validation (check proofs at the mint, roll over survivors) —
implemented as reconciliation on sync, not real-time locking.
- Import replaces the unspent proof set wholesale; the serialized job
queue closes the in-flight-operation window, and mint-side double-spend
rejection remains the backstop.
- Tracking cdk via git main is a moving target; the lockfile pins a rev
and must be bumped deliberately (same policy as the rust-nostr git dep).
Watch for the next cdk release line built against nostr 0.45.x — moving
back to crates.io then removes the moving-target concern.
- Web target gets no wallet in v1: cdk store backends (redb, sqlite) need
a real filesystem and do not build on wasm32; the crate compiles to
nothing there (auto_update pattern). A wasm store would need a cdk
`WalletDatabase` over IndexedDB — explicitly out of scope.
- `7374` quote events are skipped (local cdk state per the spec); pending
quotes are not visible to other devices until claimed.
+5 -5
View File
@@ -21,12 +21,12 @@ if [[ "$1" == "--release" ]]; then
echo -e "${YELLOW}Building in release mode${NC}" echo -e "${YELLOW}Building in release mode${NC}"
fi fi
NIGHTLY="nightly-2026-10-31" # wasm_thread requires a nightly compiler for its atomics features
CARGO="cargo +$NIGHTLY" CARGO="cargo +nightly"
# Step 0: Toolchain prerequisites # Step 0: Toolchain prerequisites
echo -e "${GREEN}Step 0: Checking toolchain...${NC}" echo -e "${GREEN}Step 0: Checking toolchain...${NC}"
rustup target add wasm32-unknown-unknown --toolchain "$NIGHTLY" rustup target add wasm32-unknown-unknown --toolchain nightly
if [[ "$(uname)" == "Darwin" ]]; then if [[ "$(uname)" == "Darwin" ]]; then
# secp256k1-sys compiles C for wasm; Apple clang has the wasm32 target and # secp256k1-sys compiles C for wasm; Apple clang has the wasm32 target and
@@ -35,7 +35,7 @@ if [[ "$(uname)" == "Darwin" ]]; then
echo -e "${RED}Apple clang not found. Install the Xcode command line tools: xcode-select --install${NC}" echo -e "${RED}Apple clang not found. Install the Xcode command line tools: xcode-select --install${NC}"
exit 1 exit 1
fi fi
rustup component add llvm-tools-preview --toolchain "$NIGHTLY" rustup component add llvm-tools-preview --toolchain nightly
RUSTUP_HOME=$(rustup show home) RUSTUP_HOME=$(rustup show home)
if [[ "$(uname -m)" == "arm64" ]]; then if [[ "$(uname -m)" == "arm64" ]]; then
@@ -43,7 +43,7 @@ if [[ "$(uname)" == "Darwin" ]]; then
else else
HOST_ARCH="x86_64-apple-darwin" HOST_ARCH="x86_64-apple-darwin"
fi fi
LLVM_BIN="$RUSTUP_HOME/toolchains/$NIGHTLY-$HOST_ARCH/lib/rustlib/$HOST_ARCH/bin" LLVM_BIN="$RUSTUP_HOME/toolchains/nightly-$HOST_ARCH/lib/rustlib/$HOST_ARCH/bin"
export CC_wasm32_unknown_unknown=clang export CC_wasm32_unknown_unknown=clang
export AR_wasm32_unknown_unknown="$LLVM_BIN/llvm-ar" export AR_wasm32_unknown_unknown="$LLVM_BIN/llvm-ar"