Skip to main content

zync_core/
app.rs

1//! Crux App - cross-platform wallet core
2//!
3//! This is the shared business logic that works across all platforms:
4//! - Desktop (egui shell)
5//! - Android (Jetpack Compose shell)
6//! - iOS (SwiftUI shell)
7//! - Web (WASM)
8
9use crux_core::{render::{render, RenderOperation}, App, Command, Request};
10use serde::{Deserialize, Serialize};
11
12/// Events from shell to core
13#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
14pub enum Event {
15    // Lifecycle
16    Init,
17
18    // Auth
19    CreateWallet { password: String },
20    RestoreWallet { seed_phrase: String, password: String, birthday: u32 },
21    Login { password: String },
22    Logout,
23
24    // Sync
25    StartSync,
26    SyncProgress { height: u32, total: u32 },
27    SyncComplete { height: u32 },
28    SyncError { message: String },
29
30    // Wallet
31    RefreshBalance,
32    BalanceUpdated { balance: u64 },
33
34    // Send
35    PrepareSend { address: String, amount: u64, memo: Option<String> },
36    ConfirmSend,
37    SendComplete { txid: String },
38    SendError { message: String },
39
40    // Receive
41    GenerateAddress,
42    AddressGenerated { address: String },
43
44    // Contacts
45    AddContact { name: String, address: String },
46    DeleteContact { id: String },
47
48    // Chat
49    SendMessage { contact_id: String, message: String },
50    MessageReceived { contact_id: String, message: String, timestamp: u64 },
51
52    // Settings
53    SetServer { url: String },
54    SetInsecureMode { enabled: bool },
55}
56
57/// App state (owned by core)
58#[derive(Debug, Default)]
59pub struct Model {
60    // Auth state
61    pub is_logged_in: bool,
62    pub wallet_exists: bool,
63
64    // Wallet data
65    pub seed_phrase: Option<String>,
66    pub viewing_key: Option<String>,
67    pub balance: u64,
68    pub birthday_height: u32,
69
70    // Sync state
71    pub is_syncing: bool,
72    pub sync_height: u32,
73    pub chain_height: u32,
74    pub gigaproof_verified: bool,
75
76    // Server
77    pub server_url: String,
78    pub insecure_mode: bool,
79
80    // Pending tx
81    pub pending_address: Option<String>,
82    pub pending_amount: u64,
83    pub pending_memo: Option<String>,
84
85    // Contacts
86    pub contacts: Vec<Contact>,
87
88    // Chat
89    pub messages: Vec<ChatMessage>,
90
91    // Error state
92    pub error: Option<String>,
93}
94
95/// Contact entry
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
97pub struct Contact {
98    pub id: String,
99    pub name: String,
100    pub address: String,
101    pub last_message: Option<u64>,
102    pub unread: u32,
103}
104
105/// Chat message
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
107pub struct ChatMessage {
108    pub id: String,
109    pub contact_id: String,
110    pub content: String,
111    pub timestamp: u64,
112    pub is_outgoing: bool,
113    pub txid: Option<String>,
114}
115
116/// ViewModel sent to shell for rendering
117#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
118pub struct ViewModel {
119    // Auth
120    pub is_logged_in: bool,
121    pub wallet_exists: bool,
122
123    // Balance
124    pub balance_zat: u64,
125    pub balance_zec: String,
126
127    // Sync
128    pub is_syncing: bool,
129    pub sync_progress: f32,
130    pub sync_height: u32,
131    pub chain_height: u32,
132    pub is_verified: bool,
133
134    // Server
135    pub server_url: String,
136    pub insecure_mode: bool,
137
138    // Address
139    pub receive_address: Option<String>,
140
141    // Pending tx
142    pub has_pending_tx: bool,
143    pub pending_address: Option<String>,
144    pub pending_amount: u64,
145
146    // Contacts
147    pub contacts: Vec<Contact>,
148    pub total_unread: u32,
149
150    // Error
151    pub error: Option<String>,
152}
153
154/// Effect enum for capabilities
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub enum Effect {
157    Render(RenderOperation),
158}
159
160// Effect must be Send + 'static
161impl crux_core::Effect for Effect {}
162
163impl From<Request<RenderOperation>> for Effect {
164    fn from(req: Request<RenderOperation>) -> Self {
165        Effect::Render(req.operation)
166    }
167}
168
169/// The Crux App
170#[derive(Default)]
171pub struct ZafuCore;
172
173impl App for ZafuCore {
174    type Event = Event;
175    type Model = Model;
176    type ViewModel = ViewModel;
177    type Capabilities = ();
178    type Effect = Effect;
179
180    fn update(
181        &self,
182        event: Self::Event,
183        model: &mut Self::Model,
184        _caps: &Self::Capabilities,
185    ) -> Command<Self::Effect, Self::Event> {
186        match event {
187            Event::Init => {
188                // initial setup
189            }
190
191            Event::CreateWallet { password: _ } => {
192                // generate new seed phrase
193                let mnemonic = bip39::Mnemonic::generate(24)
194                    .expect("failed to generate mnemonic");
195                model.seed_phrase = Some(mnemonic.to_string());
196                model.wallet_exists = true;
197                model.is_logged_in = true;
198            }
199
200            Event::RestoreWallet { seed_phrase, password: _, birthday } => {
201                // validate seed phrase
202                match bip39::Mnemonic::parse(&seed_phrase) {
203                    Ok(_) => {
204                        model.seed_phrase = Some(seed_phrase);
205                        model.birthday_height = birthday;
206                        model.wallet_exists = true;
207                        model.is_logged_in = true;
208                        model.error = None;
209                    }
210                    Err(e) => {
211                        model.error = Some(format!("invalid seed phrase: {}", e));
212                    }
213                }
214            }
215
216            Event::Login { password: _ } => {
217                model.is_logged_in = true;
218            }
219
220            Event::Logout => {
221                model.is_logged_in = false;
222                model.seed_phrase = None;
223                model.viewing_key = None;
224            }
225
226            Event::StartSync => {
227                model.is_syncing = true;
228                model.error = None;
229            }
230
231            Event::SyncProgress { height, total } => {
232                model.sync_height = height;
233                model.chain_height = total;
234            }
235
236            Event::SyncComplete { height } => {
237                model.is_syncing = false;
238                model.sync_height = height;
239                model.gigaproof_verified = true;
240            }
241
242            Event::SyncError { message } => {
243                model.is_syncing = false;
244                model.error = Some(message);
245            }
246
247            Event::RefreshBalance => {
248                // recalculate from scanned notes
249            }
250
251            Event::BalanceUpdated { balance } => {
252                model.balance = balance;
253            }
254
255            Event::PrepareSend { address, amount, memo } => {
256                model.pending_address = Some(address);
257                model.pending_amount = amount;
258                model.pending_memo = memo;
259            }
260
261            Event::ConfirmSend => {
262                // build and broadcast transaction
263            }
264
265            Event::SendComplete { txid: _ } => {
266                model.pending_address = None;
267                model.pending_amount = 0;
268                model.pending_memo = None;
269            }
270
271            Event::SendError { message } => {
272                model.error = Some(message);
273            }
274
275            Event::GenerateAddress => {
276                // derive next address from viewing key
277            }
278
279            Event::AddressGenerated { address: _ } => {
280                // address generated
281            }
282
283            Event::AddContact { name, address } => {
284                let id = format!("{:x}", rand::random::<u64>());
285                model.contacts.push(Contact {
286                    id,
287                    name,
288                    address,
289                    last_message: None,
290                    unread: 0,
291                });
292            }
293
294            Event::DeleteContact { id } => {
295                model.contacts.retain(|c| c.id != id);
296            }
297
298            Event::SendMessage { contact_id, message } => {
299                let msg = ChatMessage {
300                    id: format!("{:x}", rand::random::<u64>()),
301                    contact_id,
302                    content: message,
303                    timestamp: std::time::SystemTime::now()
304                        .duration_since(std::time::UNIX_EPOCH)
305                        .unwrap()
306                        .as_secs(),
307                    is_outgoing: true,
308                    txid: None,
309                };
310                model.messages.push(msg);
311            }
312
313            Event::MessageReceived { contact_id, message, timestamp } => {
314                let msg = ChatMessage {
315                    id: format!("{:x}", rand::random::<u64>()),
316                    contact_id: contact_id.clone(),
317                    content: message,
318                    timestamp,
319                    is_outgoing: false,
320                    txid: None,
321                };
322                model.messages.push(msg);
323
324                // update unread count
325                if let Some(contact) = model.contacts.iter_mut().find(|c| c.id == contact_id) {
326                    contact.unread += 1;
327                    contact.last_message = Some(timestamp);
328                }
329            }
330
331            Event::SetServer { url } => {
332                model.server_url = url;
333            }
334
335            Event::SetInsecureMode { enabled } => {
336                model.insecure_mode = enabled;
337            }
338        }
339
340        // always render after state change
341        render()
342    }
343
344    fn view(&self, model: &Self::Model) -> Self::ViewModel {
345        let sync_progress = if model.chain_height > 0 {
346            model.sync_height as f32 / model.chain_height as f32
347        } else {
348            0.0
349        };
350
351        ViewModel {
352            is_logged_in: model.is_logged_in,
353            wallet_exists: model.wallet_exists,
354            balance_zat: model.balance,
355            balance_zec: format!("{:.8}", model.balance as f64 / 100_000_000.0),
356            is_syncing: model.is_syncing,
357            sync_progress,
358            sync_height: model.sync_height,
359            chain_height: model.chain_height,
360            is_verified: model.gigaproof_verified,
361            server_url: model.server_url.clone(),
362            insecure_mode: model.insecure_mode,
363            receive_address: model.viewing_key.clone(),
364            has_pending_tx: model.pending_address.is_some(),
365            pending_address: model.pending_address.clone(),
366            pending_amount: model.pending_amount,
367            contacts: model.contacts.clone(),
368            total_unread: model.contacts.iter().map(|c| c.unread).sum(),
369            error: model.error.clone(),
370        }
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use crux_core::testing::AppTester;
378
379    #[test]
380    fn test_create_wallet() {
381        let app = AppTester::<ZafuCore>::default();
382        let mut model = Model::default();
383
384        let event = Event::CreateWallet {
385            password: "test123".to_string(),
386        };
387
388        let _effects = app.update(event, &mut model);
389
390        assert!(model.is_logged_in);
391        assert!(model.wallet_exists);
392        assert!(model.seed_phrase.is_some());
393    }
394
395    #[test]
396    fn test_restore_wallet_invalid() {
397        let app = AppTester::<ZafuCore>::default();
398        let mut model = Model::default();
399
400        let event = Event::RestoreWallet {
401            seed_phrase: "invalid seed".to_string(),
402            password: "test".to_string(),
403            birthday: 1000000,
404        };
405
406        let _effects = app.update(event, &mut model);
407
408        assert!(!model.is_logged_in);
409        assert!(model.error.is_some());
410    }
411}