1use crux_core::{render::{render, RenderOperation}, App, Command, Request};
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
14pub enum Event {
15 Init,
17
18 CreateWallet { password: String },
20 RestoreWallet { seed_phrase: String, password: String, birthday: u32 },
21 Login { password: String },
22 Logout,
23
24 StartSync,
26 SyncProgress { height: u32, total: u32 },
27 SyncComplete { height: u32 },
28 SyncError { message: String },
29
30 RefreshBalance,
32 BalanceUpdated { balance: u64 },
33
34 PrepareSend { address: String, amount: u64, memo: Option<String> },
36 ConfirmSend,
37 SendComplete { txid: String },
38 SendError { message: String },
39
40 GenerateAddress,
42 AddressGenerated { address: String },
43
44 AddContact { name: String, address: String },
46 DeleteContact { id: String },
47
48 SendMessage { contact_id: String, message: String },
50 MessageReceived { contact_id: String, message: String, timestamp: u64 },
51
52 SetServer { url: String },
54 SetInsecureMode { enabled: bool },
55}
56
57#[derive(Debug, Default)]
59pub struct Model {
60 pub is_logged_in: bool,
62 pub wallet_exists: bool,
63
64 pub seed_phrase: Option<String>,
66 pub viewing_key: Option<String>,
67 pub balance: u64,
68 pub birthday_height: u32,
69
70 pub is_syncing: bool,
72 pub sync_height: u32,
73 pub chain_height: u32,
74 pub gigaproof_verified: bool,
75
76 pub server_url: String,
78 pub insecure_mode: bool,
79
80 pub pending_address: Option<String>,
82 pub pending_amount: u64,
83 pub pending_memo: Option<String>,
84
85 pub contacts: Vec<Contact>,
87
88 pub messages: Vec<ChatMessage>,
90
91 pub error: Option<String>,
93}
94
95#[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#[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#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
118pub struct ViewModel {
119 pub is_logged_in: bool,
121 pub wallet_exists: bool,
122
123 pub balance_zat: u64,
125 pub balance_zec: String,
126
127 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 pub server_url: String,
136 pub insecure_mode: bool,
137
138 pub receive_address: Option<String>,
140
141 pub has_pending_tx: bool,
143 pub pending_address: Option<String>,
144 pub pending_amount: u64,
145
146 pub contacts: Vec<Contact>,
148 pub total_unread: u32,
149
150 pub error: Option<String>,
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub enum Effect {
157 Render(RenderOperation),
158}
159
160impl 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#[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 }
190
191 Event::CreateWallet { password: _ } => {
192 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 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 }
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 }
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 }
278
279 Event::AddressGenerated { address: _ } => {
280 }
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 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 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}