dash-shielded-native 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +27 -0
  3. package/README.md +36 -0
  4. package/android/build.gradle +56 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/app/edge/rndashshielded/RNDashShieldedModule.kt +215 -0
  7. package/android/src/main/java/app/edge/rndashshielded/RNDashShieldedPackage.kt +16 -0
  8. package/android/src/main/java/uniffi/dash/dash.kt +1665 -0
  9. package/android/src/main/jniLibs/arm64-v8a/libdashshielded.so +0 -0
  10. package/dash-shielded-native.podspec +32 -0
  11. package/ios/.uniffi-generated +1 -0
  12. package/ios/EdgeDashClient.swift +88 -0
  13. package/ios/RNDashShielded.m +83 -0
  14. package/ios/RNDashShielded.swift +214 -0
  15. package/ios/dash-shielded-native-Bridging-Header.h +2 -0
  16. package/ios/dash.swift +1124 -0
  17. package/ios/libdashshielded.xcframework/Info.plist +47 -0
  18. package/ios/libdashshielded.xcframework/ios-arm64/Headers/dashFFI.h +696 -0
  19. package/ios/libdashshielded.xcframework/ios-arm64/Headers/module.modulemap +7 -0
  20. package/ios/libdashshielded.xcframework/ios-arm64/libdashshielded.a +0 -0
  21. package/ios/libdashshielded.xcframework/ios-arm64-simulator/Headers/dashFFI.h +696 -0
  22. package/ios/libdashshielded.xcframework/ios-arm64-simulator/Headers/module.modulemap +7 -0
  23. package/ios/libdashshielded.xcframework/ios-arm64-simulator/libdashshielded.a +0 -0
  24. package/lib/load-addon.d.ts +34 -0
  25. package/lib/node.d.ts +50 -0
  26. package/lib/react-native.d.ts +31 -0
  27. package/lib/rndash.rn.js +267 -0
  28. package/lib/rndash.rn.js.map +1 -0
  29. package/lib/src/node.js +328 -0
  30. package/lib/src/node.js.map +1 -0
  31. package/lib/types.d.ts +73 -0
  32. package/node.d.ts +1 -0
  33. package/node.js +2 -0
  34. package/package.json +105 -0
  35. package/prebuilds/darwin-arm64/dashshielded.node +0 -0
  36. package/rust/Cargo.lock +6795 -0
  37. package/rust/Cargo.toml +63 -0
  38. package/rust/build.rs +7 -0
  39. package/rust/rust-toolchain.toml +3 -0
  40. package/rust/src/dash.udl +90 -0
  41. package/rust/src/lib.rs +16 -0
  42. package/rust/src/napi_api.rs +164 -0
  43. package/rust/src/uniffi_api.rs +116 -0
  44. package/rust/src/wallet.rs +888 -0
  45. package/rust/uniffi-bindgen.rs +3 -0
  46. package/src/load-addon.ts +99 -0
  47. package/src/node.ts +248 -0
  48. package/src/react-native.ts +212 -0
  49. package/src/types.ts +88 -0
@@ -0,0 +1,888 @@
1
+ use std::collections::HashMap;
2
+ use std::path::PathBuf;
3
+ use std::sync::{Arc, Mutex};
4
+
5
+ use rs_sdk_trusted_context_provider::TrustedHttpContextProvider;
6
+ use dash_sdk::sdk::{Address as SdkAddress, AddressList};
7
+ use dash_sdk::SdkBuilder;
8
+ use dashcore::Network;
9
+ use key_wallet::wallet::initialization::WalletAccountCreationOptions;
10
+ use platform_wallet::events::{EventHandler, PlatformEventHandler};
11
+ use platform_wallet::manager::shielded_sync::WalletShieldedOutcome;
12
+ use platform_wallet::wallet::shielded::store::{ShieldedStore, SubwalletId};
13
+ use platform_wallet::wallet::shielded::{ShieldedActivityStatus, ShieldedDirection};
14
+ use platform_wallet::changeset::{
15
+ ClientStartState, PersistenceCapabilities, PersistenceError, PlatformWalletChangeSet,
16
+ PlatformWalletPersistence,
17
+ };
18
+ use platform_wallet::wallet::platform_wallet::WalletId as PwWalletId;
19
+ use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig};
20
+ use platform_wallet::wallet::platform_wallet::{PlatformWallet, WalletId};
21
+ use platform_wallet::PlatformWalletManager;
22
+
23
+ use bech32::{Bech32m, Hrp};
24
+ use bip0039::{Count, English, Mnemonic};
25
+ use once_cell::sync::Lazy;
26
+ use orchard::keys::{FullViewingKey, Scope, SpendingKey};
27
+ use orchard::Address;
28
+ use zip32::AccountId;
29
+
30
+ pub type WalletResult<T> = Result<T, String>;
31
+
32
+ /// SQLite persistence plus an attestation that shielded viewing keys survive.
33
+ ///
34
+ /// `bind_shielded` demands `SHIELDED_FVK_RESTART`, which the stock
35
+ /// `SqlitePersister` deliberately withholds ("shielded state lives in a
36
+ /// separate store"). That capability exists for *seedless* rebinding — a host
37
+ /// that reopens a shielded wallet without the mnemonic and needs the viewing
38
+ /// keys read back. This wallet never does that: `initialize` is always handed
39
+ /// the mnemonic and re-derives the keys through `bind_shielded` on every open,
40
+ /// and the notes themselves live in the coordinator's own SQLite store, which
41
+ /// does persist. Attesting the capability is therefore accurate for how this
42
+ /// wallet is driven; a caller who later wants seedless restart must implement
43
+ /// real viewing-key rows here first.
44
+ struct ShieldedCapablePersister {
45
+ inner: SqlitePersister,
46
+ }
47
+
48
+ impl PlatformWalletPersistence for ShieldedCapablePersister {
49
+ fn store(
50
+ &self,
51
+ wallet_id: PwWalletId,
52
+ changeset: PlatformWalletChangeSet,
53
+ ) -> Result<(), PersistenceError> {
54
+ self.inner.store(wallet_id, changeset)
55
+ }
56
+
57
+ fn flush(&self, wallet_id: PwWalletId) -> Result<(), PersistenceError> {
58
+ self.inner.flush(wallet_id)
59
+ }
60
+
61
+ fn load(&self) -> Result<ClientStartState, PersistenceError> {
62
+ self.inner.load()
63
+ }
64
+
65
+ fn persistence_capabilities(&self) -> PersistenceCapabilities {
66
+ self.inner
67
+ .persistence_capabilities()
68
+ .union(PersistenceCapabilities::SHIELDED_VIEWING_KEYS)
69
+ }
70
+ }
71
+
72
+ struct SilentEventHandler;
73
+ impl EventHandler for SilentEventHandler {}
74
+ impl PlatformEventHandler for SilentEventHandler {}
75
+
76
+ type Manager = PlatformWalletManager<ShieldedCapablePersister>;
77
+
78
+ fn network_from_name(name: &str) -> Network {
79
+ match name {
80
+ "mainnet" => Network::Mainnet,
81
+ "testnet" => Network::Testnet,
82
+ "devnet" => Network::Devnet,
83
+ _ => Network::Regtest,
84
+ }
85
+ }
86
+
87
+ const ORCHARD_TYPE: u8 = 0x10;
88
+ const MAINNET_HRP: &str = "dash";
89
+ const TESTNET_HRP: &str = "tdash";
90
+
91
+ pub struct ClientSlot {
92
+ pub mnemonic: String,
93
+ pub network: String,
94
+ pub account: u32,
95
+ pub address: String,
96
+ pub viewing_key: String,
97
+ pub status: String,
98
+ pub available_credits: String,
99
+ pub total_credits: String,
100
+ pub proposals: HashMap<String, PendingProposal>,
101
+ /// Live Dash Platform wallet manager. Owns the SDK connection and the
102
+ /// shielded coordinator that drives note scanning.
103
+ pub manager: Option<Arc<Manager>>,
104
+ pub wallet: Option<Arc<PlatformWallet>>,
105
+ /// Commitments walked by the most recent sync pass, for scan progress.
106
+ pub total_scanned: u64,
107
+ pub network_block_height: u32,
108
+ }
109
+
110
+ pub struct PendingProposal {
111
+ pub to_address: String,
112
+ pub amount_credits: String,
113
+ pub memo: String,
114
+ }
115
+
116
+ static DOCUMENT_DIR: Lazy<Mutex<Option<PathBuf>>> = Lazy::new(|| Mutex::new(None));
117
+ static CLIENTS: Lazy<tokio::sync::Mutex<HashMap<String, ClientSlot>>> =
118
+ Lazy::new(|| tokio::sync::Mutex::new(HashMap::new()));
119
+ static PROVER_READY: Lazy<Mutex<bool>> = Lazy::new(|| Mutex::new(false));
120
+ /// Highest Platform height reported by a sync chunk, per alias. Written from
121
+ /// the coordinator's progress callback, which runs on the sync task.
122
+ static SYNC_HEIGHTS: Lazy<Mutex<HashMap<String, u64>>> =
123
+ Lazy::new(|| Mutex::new(HashMap::new()));
124
+
125
+ pub struct Addresses {
126
+ pub shielded_address: String,
127
+ }
128
+
129
+ pub struct Transaction {
130
+ pub txid: String,
131
+ pub block_time_in_seconds: i64,
132
+ pub mined_height: i64,
133
+ pub value: String,
134
+ pub fee: Option<String>,
135
+ pub to_address: Option<String>,
136
+ pub memos: Vec<String>,
137
+ }
138
+
139
+ pub struct Poll {
140
+ pub alias: String,
141
+ pub status: String,
142
+ pub scan_progress: f64,
143
+ pub network_block_height: u32,
144
+ pub available_credits: String,
145
+ pub total_credits: String,
146
+ pub transactions: Vec<Transaction>,
147
+ }
148
+
149
+ fn coin_type(network: &str) -> u32 {
150
+ if network == "testnet" {
151
+ 1
152
+ } else {
153
+ 5
154
+ }
155
+ }
156
+
157
+ fn hrp_for(network: &str) -> WalletResult<Hrp> {
158
+ let s = if network == "testnet" {
159
+ TESTNET_HRP
160
+ } else {
161
+ MAINNET_HRP
162
+ };
163
+ Hrp::parse(s).map_err(|e| e.to_string())
164
+ }
165
+
166
+ fn mnemonic_to_seed(mnemonic_seed: &str) -> WalletResult<[u8; 64]> {
167
+ let trimmed = mnemonic_seed.trim();
168
+ if let Ok(bytes) = hex::decode(trimmed) {
169
+ if bytes.len() == 64 {
170
+ let mut out = [0u8; 64];
171
+ out.copy_from_slice(&bytes);
172
+ return Ok(out);
173
+ }
174
+ if bytes.len() == 32 {
175
+ // Treat raw entropy as a 24-word seed by wrapping as BIP39 entropy.
176
+ }
177
+ }
178
+ let mnemonic = Mnemonic::<English>::from_phrase(trimmed)
179
+ .map_err(|e| format!("invalid mnemonic: {e}"))?;
180
+ Ok(mnemonic.to_seed(""))
181
+ }
182
+
183
+ fn spending_key(mnemonic_seed: &str, network: &str, account: u32) -> WalletResult<SpendingKey> {
184
+ let seed = mnemonic_to_seed(mnemonic_seed)?;
185
+ let account_id = AccountId::try_from(account).map_err(|e| format!("account: {e}"))?;
186
+ SpendingKey::from_zip32_seed(&seed, coin_type(network), account_id)
187
+ .map_err(|e| format!("zip32: {e}"))
188
+ }
189
+
190
+ fn encode_orchard_address(address: &Address, network: &str) -> WalletResult<String> {
191
+ let raw = address.to_raw_address_bytes();
192
+ let mut payload = Vec::with_capacity(1 + raw.len());
193
+ payload.push(ORCHARD_TYPE);
194
+ payload.extend_from_slice(&raw);
195
+ let hrp = hrp_for(network)?;
196
+ bech32::encode::<Bech32m>(hrp, &payload).map_err(|e| e.to_string())
197
+ }
198
+
199
+ /// Bech32m-encode a raw 43-byte Orchard payment address as returned by
200
+ /// `PlatformWallet::shielded_default_address`.
201
+ fn encode_raw_orchard_address(raw: &[u8; 43], network: &str) -> WalletResult<String> {
202
+ let mut payload = Vec::with_capacity(1 + raw.len());
203
+ payload.push(ORCHARD_TYPE);
204
+ payload.extend_from_slice(raw);
205
+ let hrp = hrp_for(network)?;
206
+ bech32::encode::<Bech32m>(hrp, &payload).map_err(|e| e.to_string())
207
+ }
208
+
209
+ fn derive_address_and_fvk(
210
+ mnemonic_seed: &str,
211
+ network: &str,
212
+ account: u32,
213
+ ) -> WalletResult<(String, String)> {
214
+ let sk = spending_key(mnemonic_seed, network, account)?;
215
+ let fvk = FullViewingKey::from(&sk);
216
+ let address = fvk.address_at(0u32, Scope::External);
217
+ let encoded = encode_orchard_address(&address, network)?;
218
+ Ok((encoded, hex::encode(fvk.to_bytes())))
219
+ }
220
+
221
+ pub fn is_valid_address(address: String, network: String) -> bool {
222
+ let Ok((hrp, data)) = bech32::decode(&address) else {
223
+ return false;
224
+ };
225
+ let expected = if network == "testnet" {
226
+ TESTNET_HRP
227
+ } else {
228
+ MAINNET_HRP
229
+ };
230
+ if hrp.as_str() != expected {
231
+ return false;
232
+ }
233
+ data.len() == 44 && data[0] == ORCHARD_TYPE
234
+ }
235
+
236
+ pub fn derive_viewing_key(mnemonic_seed: String, network: String) -> WalletResult<String> {
237
+ let (_addr, fvk) = derive_address_and_fvk(&mnemonic_seed, &network, 0)?;
238
+ Ok(fvk)
239
+ }
240
+
241
+ pub fn set_document_directory(path: String) -> WalletResult<()> {
242
+ let mut dir = DOCUMENT_DIR.lock().map_err(|e| e.to_string())?;
243
+ *dir = Some(PathBuf::from(path));
244
+ Ok(())
245
+ }
246
+
247
+ /// Best-effort Bech32m rendering of an activity counterparty. Entries record
248
+ /// the raw 43-byte Orchard address when they know it; anything else (a
249
+ /// transparent counterparty, or an unknown sender on a received note) is
250
+ /// surfaced as hex rather than dropped.
251
+ fn encode_raw_counterparty(raw: &[u8], network: &str) -> String {
252
+ if raw.len() == 43 {
253
+ let mut fixed = [0u8; 43];
254
+ fixed.copy_from_slice(raw);
255
+ if let Ok(encoded) = encode_raw_orchard_address(&fixed, network) {
256
+ return encoded;
257
+ }
258
+ }
259
+ hex::encode(raw)
260
+ }
261
+
262
+ /// Ask the quorum service which masternodes are currently usable.
263
+ ///
264
+ /// Returns the IPs of nodes the service reports `ENABLED` with a passing
265
+ /// `versionCheck`. A node that fails the version check is running an older
266
+ /// Platform build whose gRPC surface lacks the shielded queries.
267
+ async fn fetch_active_masternodes(quorum_base: &str) -> WalletResult<Vec<String>> {
268
+ let url = format!("{}/masternodes", quorum_base.trim_end_matches('/'));
269
+ let body = reqwest::get(&url)
270
+ .await
271
+ .map_err(|e| format!("masternode list: {e}"))?
272
+ .text()
273
+ .await
274
+ .map_err(|e| format!("masternode list body: {e}"))?;
275
+ let parsed = json::parse(&body).map_err(|e| format!("masternode list json: {e}"))?;
276
+
277
+ let mut out = Vec::new();
278
+ for row in parsed["data"].members() {
279
+ if row["status"].as_str() != Some("ENABLED") {
280
+ continue;
281
+ }
282
+ if row["versionCheck"].as_str() != Some("success") {
283
+ continue;
284
+ }
285
+ if let Some(addr) = row["address"].as_str() {
286
+ // `address` is the Core P2P endpoint (`ip:9999`); Platform gRPC
287
+ // is served on 443 of the same host.
288
+ if let Some((ip, _port)) = addr.rsplit_once(':') {
289
+ out.push(ip.to_string());
290
+ }
291
+ }
292
+ }
293
+ Ok(out)
294
+ }
295
+
296
+ pub async fn initialize(
297
+ mnemonic_seed: String,
298
+ account: u32,
299
+ alias: String,
300
+ network_name: String,
301
+ default_host: String,
302
+ default_port: u32,
303
+ ) -> WalletResult<()> {
304
+ let network = network_from_name(&network_name);
305
+
306
+ // DAPI endpoints, discovered live from the same quorum service the Dash
307
+ // Wallet apps use. The baked-in `dash-network-seeds` list is a snapshot and
308
+ // includes nodes still on older Platform builds; those answer the shielded
309
+ // RPC with gRPC UNIMPLEMENTED. Filtering on the service's own
310
+ // `versionCheck` keeps us on nodes that actually implement it.
311
+ let quorum_base = match network {
312
+ Network::Mainnet => "https://quorums.mainnet.networks.dash.org",
313
+ Network::Testnet => "https://quorums.testnet.networks.dash.org",
314
+ _ => "",
315
+ };
316
+
317
+ let mut endpoints: Vec<SdkAddress> = Vec::new();
318
+ if !quorum_base.is_empty() {
319
+ if let Ok(rows) = fetch_active_masternodes(quorum_base).await {
320
+ endpoints = rows
321
+ .into_iter()
322
+ .filter_map(|ip| format!("https://{ip}:443").parse().ok())
323
+ .collect();
324
+ }
325
+ }
326
+
327
+ // Caller-supplied host is the fallback (devnets, custom deployments).
328
+ let scheme = if default_port == 443 { "https" } else { "http" };
329
+ if endpoints.is_empty() {
330
+ if let Ok(explicit) =
331
+ format!("{}://{}:{}", scheme, default_host, default_port).parse::<SdkAddress>()
332
+ {
333
+ endpoints.push(explicit);
334
+ }
335
+ }
336
+ if endpoints.is_empty() {
337
+ return Err(format!(
338
+ "no DAPI endpoints for {network:?} (host {default_host}:{default_port})"
339
+ ));
340
+ }
341
+
342
+ // Quorum keys come from Dash's trusted quorum service, the same source the
343
+ // Dash Wallet apps use. It needs no Dash Core RPC, and `new` resolves the
344
+ // built-in base URL for the network (quorums.<net>.networks.dash.org).
345
+ let context_provider = TrustedHttpContextProvider::new(
346
+ network,
347
+ None,
348
+ std::num::NonZeroUsize::new(100).expect("non-zero"),
349
+ )
350
+ .map_err(|e| format!("trusted context provider: {e}"))?;
351
+
352
+ let sdk = SdkBuilder::new(AddressList::from_iter(endpoints))
353
+ .with_network(network)
354
+ .with_context_provider(context_provider)
355
+ .build()
356
+ .map_err(|e| format!("build sdk: {e}"))?;
357
+
358
+ // Wallet state lives in SQLite under the host's document directory. The
359
+ // shielded path requires a persister advertising `atomic_changesets` and
360
+ // `shielded_viewing_keys`, which the in-tree SQLite persister provides.
361
+ let base = {
362
+ let dir = DOCUMENT_DIR.lock().map_err(|e| e.to_string())?;
363
+ dir.clone().unwrap_or_else(std::env::temp_dir)
364
+ };
365
+ let db_dir = base.join("dash-shielded").join(&alias);
366
+ std::fs::create_dir_all(&db_dir).map_err(|e| format!("mkdir {db_dir:?}: {e}"))?;
367
+
368
+ let inner = SqlitePersister::open(SqlitePersisterConfig::new(db_dir.join("wallet.db")))
369
+ .map_err(|e| format!("open wallet store: {e}"))?;
370
+ let persister = ShieldedCapablePersister { inner };
371
+
372
+ let manager = Arc::new(Manager::new(
373
+ Arc::new(sdk),
374
+ Arc::new(persister),
375
+ Arc::new(SilentEventHandler) as Arc<dyn PlatformEventHandler>,
376
+ ));
377
+
378
+ // Shielded note store: one SQLite file per alias under the host's
379
+ // document directory, so wallets never share note state.
380
+ manager
381
+ .configure_shielded(db_dir.join("shielded.db"))
382
+ .await
383
+ .map_err(|e| format!("configure_shielded: {e}"))?;
384
+
385
+ // The per-chunk progress callback carries the Platform height each chunk
386
+ // was proven at. Record it so the host can report how far the scan has
387
+ // actually reached rather than only how many commitments it walked.
388
+ if let Some(coordinator) = manager.shielded_coordinator().await {
389
+ let alias_for_progress = alias.clone();
390
+ coordinator.install_progress_handler(Some(Arc::new(
391
+ move |_downloaded: u64, height: u64| {
392
+ if height == 0 {
393
+ return;
394
+ }
395
+ if let Ok(mut heights) = SYNC_HEIGHTS.lock() {
396
+ heights.insert(alias_for_progress.clone(), height);
397
+ }
398
+ },
399
+ )));
400
+ }
401
+
402
+ // Both layers derive from the whole 64-byte BIP39 seed. `bind_shielded`
403
+ // takes a slice and runs ZIP-32 over exactly what it is given, so the seed
404
+ // must not be truncated: feeding it the first 32 bytes yields a different
405
+ // master key and therefore a different receive address, orphaning any
406
+ // funds already sent to the wallet.
407
+ let mnemonic = <Mnemonic<English>>::from_phrase(mnemonic_seed.clone())
408
+ .map_err(|e| format!("bad mnemonic: {e}"))?;
409
+ let seed64 = mnemonic.to_seed("");
410
+ let shielded_seed = seed64;
411
+
412
+ let wallet = manager
413
+ .create_wallet_from_seed_bytes(
414
+ network,
415
+ &seed64,
416
+ WalletAccountCreationOptions::Default,
417
+ None,
418
+ )
419
+ .await
420
+ .map_err(|e| format!("create_wallet_from_seed_bytes: {e}"))?;
421
+
422
+ let coordinator = manager
423
+ .shielded_coordinator()
424
+ .await
425
+ .ok_or("shielded coordinator missing after configure_shielded")?;
426
+ wallet
427
+ .bind_shielded(&shielded_seed[..], &[account], &coordinator)
428
+ .await
429
+ .map_err(|e| format!("bind_shielded: {e}"))?;
430
+
431
+ // The wallet hands back the raw 43-byte Orchard payment address; encode it
432
+ // in the same Bech32m form the rest of this crate speaks (`dash1z…`).
433
+ let raw = wallet
434
+ .shielded_default_address(account)
435
+ .await
436
+ .ok_or("shielded_default_address returned none (bind_shielded did not take)")?;
437
+ let address = encode_raw_orchard_address(&raw, &network_name)?;
438
+
439
+ if std::env::var("DASH_SHIELDED_DEBUG").is_ok() {
440
+ // Same mnemonic, both derivations, one process: the stub's local
441
+ // ZIP-32 path versus what bind_shielded produced.
442
+ match derive_address_and_fvk(&mnemonic_seed, &network_name, account) {
443
+ Ok((local, _)) => eprintln!("[dash-shielded] local-derived = {local}"),
444
+ Err(e) => eprintln!("[dash-shielded] local derive failed: {e}"),
445
+ }
446
+ eprintln!("[dash-shielded] bind-derived = {address}");
447
+ eprintln!("[dash-shielded] seed len = {}", shielded_seed.len());
448
+ }
449
+
450
+ let viewing_key = derive_address_and_fvk(&mnemonic_seed, &network_name, account)
451
+ .map(|(_a, fvk)| fvk)
452
+ .unwrap_or_default();
453
+
454
+ let mut clients = CLIENTS.lock().await;
455
+ clients.insert(
456
+ alias,
457
+ ClientSlot {
458
+ mnemonic: mnemonic_seed,
459
+ network: network_name,
460
+ account,
461
+ address,
462
+ viewing_key,
463
+ status: "DISCONNECTED".to_string(),
464
+ available_credits: "0".to_string(),
465
+ total_credits: "0".to_string(),
466
+ proposals: HashMap::new(),
467
+ manager: Some(manager),
468
+ wallet: Some(wallet),
469
+ total_scanned: 0,
470
+ network_block_height: 0,
471
+ },
472
+ );
473
+ Ok(())
474
+ }
475
+
476
+ pub async fn stop(alias: String) -> WalletResult<String> {
477
+ let mut clients = CLIENTS.lock().await;
478
+ clients.remove(&alias);
479
+ Ok("STOPPED".to_string())
480
+ }
481
+
482
+ pub async fn start_sync(alias: String) -> WalletResult<()> {
483
+ let manager = {
484
+ let clients = CLIENTS.lock().await;
485
+ let slot = clients.get(&alias).ok_or("unknown alias")?;
486
+ slot.manager.clone().ok_or("wallet not initialized")?
487
+ };
488
+ // Start the coordinator's periodic scan loop. It keeps running until
489
+ // `stop_sync`, so a wallet left open stays current.
490
+ manager.shielded_sync_arc().start();
491
+
492
+ // Kick one immediate pass so a freshly opened wallet does not wait out
493
+ // the interval before its first balance.
494
+ let alias_for_task = alias.clone();
495
+ let wallet_for_task = {
496
+ let clients = CLIENTS.lock().await;
497
+ clients
498
+ .get(&alias)
499
+ .and_then(|slot| slot.wallet.clone())
500
+ };
501
+ tokio::spawn(async move {
502
+ if let Some(coordinator) = manager.shielded_coordinator().await {
503
+ let summary = coordinator.sync(true).await;
504
+ if std::env::var("DASH_SHIELDED_DEBUG").is_ok() {
505
+ eprintln!("[dash-shielded] sync summary: {summary:?}");
506
+ // Local tree size vs what the pass walked: if the chain has
507
+ // more leaves than we hold, the fetch is stopping short.
508
+ if let Some(coord) = manager.shielded_coordinator().await {
509
+ if let Ok(store) = coord.store().try_read() {
510
+ let sub = wallet_for_task
511
+ .as_ref()
512
+ .map(|w| SubwalletId::new(w.wallet_id(), 0));
513
+ eprintln!(
514
+ "[dash-shielded] tree_size={:?} watermark={:?}",
515
+ store.tree_size(),
516
+ sub.map(|id| store.last_synced_note_index(id)),
517
+ );
518
+ }
519
+ }
520
+ }
521
+ // Record what the pass actually did, so `poll` reports a real
522
+ // scan rather than an assumed one.
523
+ let mut scanned = 0u64;
524
+ let mut status = "SYNCED".to_string();
525
+ if let Some(wallet) = wallet_for_task.as_ref() {
526
+ if let Some(outcome) = summary.wallet_results.get(&wallet.wallet_id()) {
527
+ match outcome {
528
+ WalletShieldedOutcome::Ok(s) => {
529
+ scanned = s.notes_result.total_scanned as u64;
530
+ }
531
+ other => {
532
+ status = format!("ERROR: {other:?}");
533
+ }
534
+ }
535
+ }
536
+ }
537
+ let mut clients = CLIENTS.lock().await;
538
+ if let Some(slot) = clients.get_mut(&alias_for_task) {
539
+ slot.total_scanned = scanned;
540
+ slot.status = status;
541
+ }
542
+ }
543
+ });
544
+
545
+ let mut clients = CLIENTS.lock().await;
546
+ let slot = clients.get_mut(&alias).ok_or("unknown alias")?;
547
+ slot.status = "SYNCING".to_string();
548
+ Ok(())
549
+ }
550
+
551
+ pub async fn stop_sync(alias: String) -> WalletResult<()> {
552
+ let mut clients = CLIENTS.lock().await;
553
+ let slot = clients.get_mut(&alias).ok_or("unknown alias")?;
554
+ if let Some(manager) = slot.manager.as_ref() {
555
+ manager.shielded_sync_arc().stop();
556
+ }
557
+ slot.status = "STOPPED".to_string();
558
+ Ok(())
559
+ }
560
+
561
+ pub async fn derive_shielded_address(alias: String) -> WalletResult<Addresses> {
562
+ let clients = CLIENTS.lock().await;
563
+ let slot = clients.get(&alias).ok_or("unknown alias")?;
564
+ Ok(Addresses {
565
+ shielded_address: slot.address.clone(),
566
+ })
567
+ }
568
+
569
+ pub async fn poll(alias: String) -> WalletResult<Poll> {
570
+ let (manager, wallet, account, network_name) = {
571
+ let clients = CLIENTS.lock().await;
572
+ let slot = clients.get(&alias).ok_or("unknown alias")?;
573
+ (
574
+ slot.manager.clone(),
575
+ slot.wallet.clone(),
576
+ slot.account,
577
+ slot.network.clone(),
578
+ )
579
+ };
580
+ let mut transactions: Vec<Transaction> = Vec::new();
581
+
582
+ // Read live shielded balance from the note store.
583
+ let mut available = "0".to_string();
584
+ let mut total = "0".to_string();
585
+ let mut status = "DISCONNECTED".to_string();
586
+ let mut scan_progress = 0.0_f64;
587
+
588
+ if let (Some(manager), Some(wallet)) = (manager.as_ref(), wallet.as_ref()) {
589
+ let sync = manager.shielded_sync_arc();
590
+ status = if sync.is_syncing() {
591
+ "SYNCING".to_string()
592
+ } else if sync.is_running() {
593
+ "SYNCED".to_string()
594
+ } else {
595
+ "STOPPED".to_string()
596
+ };
597
+ if let Some(coordinator) = manager.shielded_coordinator().await {
598
+ if let Ok(balances) = wallet.shielded_balances(&coordinator).await {
599
+ let sum: u64 = balances.values().sum();
600
+ available = sum.to_string();
601
+ total = sum.to_string();
602
+ }
603
+ }
604
+ // A completed, non-syncing pass means the wallet is current.
605
+ scan_progress = if sync.is_syncing() { 50.0 } else { 100.0 };
606
+
607
+ // Read the wallet's shielded activity: one entry per detected
608
+ // transfer, carrying amount, direction, memo and the height it was
609
+ // mined at. Notes the scan decrypted show up here.
610
+ if let Some(coordinator) = manager.shielded_coordinator().await {
611
+ let subwallet = SubwalletId::new(wallet.wallet_id(), account);
612
+ // A sync pass holds the store's write lock for its whole
613
+ // interleaved consume, so an unbounded read here would stall
614
+ // `poll` for the length of a network scan and the caller would
615
+ // time out. `try_read` is the other extreme: with the sync loop
616
+ // running continuously it never wins the race, and no activity is
617
+ // ever reported. Wait, but only briefly.
618
+ if let Ok(store) = tokio::time::timeout(
619
+ std::time::Duration::from_secs(5),
620
+ coordinator.store().read(),
621
+ )
622
+ .await
623
+ {
624
+ if let Ok(entries) = store.get_activity(subwallet, 0, 500) {
625
+ for entry in entries {
626
+ let outgoing = entry.direction == ShieldedDirection::Out;
627
+ // The host applies the sign: it reads a non-null
628
+ // `toAddress` as "this was a spend", negates
629
+ // `value + fee` itself, and leaves a receive positive.
630
+ // Report the magnitude and let it decide.
631
+ let value = entry.amount.to_string();
632
+ let memos = entry
633
+ .memo
634
+ .as_ref()
635
+ .and_then(|m| String::from_utf8(m.clone()).ok())
636
+ .filter(|m| !m.trim_matches(char::from(0)).is_empty())
637
+ .map(|m| vec![m.trim_matches(char::from(0)).to_string()])
638
+ .unwrap_or_default();
639
+ transactions.push(Transaction {
640
+ txid: hex::encode(entry.id),
641
+ // Activity records creation in milliseconds; the JS
642
+ // layer expects whole seconds.
643
+ block_time_in_seconds: (entry.created_at_ms / 1000) as i64,
644
+ mined_height: entry.block_height.unwrap_or(0) as i64,
645
+ value,
646
+ fee: entry.fee.map(|f| f.to_string()),
647
+ // Only spends carry a destination. A received note
648
+ // also records a counterparty, but surfacing it here
649
+ // would make the host book the receive as a send.
650
+ to_address: if outgoing {
651
+ entry
652
+ .counterparty
653
+ .as_ref()
654
+ .map(|c| encode_raw_counterparty(c, &network_name))
655
+ } else {
656
+ None
657
+ },
658
+ memos,
659
+ });
660
+ }
661
+ }
662
+ }
663
+ }
664
+ }
665
+
666
+ let mut clients = CLIENTS.lock().await;
667
+ let slot = clients.get_mut(&alias).ok_or("unknown alias")?;
668
+ if !slot.status.starts_with("ERROR") {
669
+ slot.status = status.clone();
670
+ }
671
+ let status = slot.status.clone();
672
+ slot.available_credits = available.clone();
673
+ slot.total_credits = total.clone();
674
+
675
+ let synced_height = SYNC_HEIGHTS
676
+ .lock()
677
+ .ok()
678
+ .and_then(|h| h.get(&alias).copied())
679
+ .unwrap_or(slot.network_block_height as u64)
680
+ .min(u32::MAX as u64) as u32;
681
+
682
+ Ok(Poll {
683
+ alias,
684
+ status,
685
+ scan_progress,
686
+ network_block_height: synced_height,
687
+ available_credits: available,
688
+ total_credits: total,
689
+ transactions,
690
+ })
691
+ }
692
+
693
+ /// Decodes a `dash1z…` / `tdash1z…` shielded address back to the raw 43-byte
694
+ /// Orchard payment address the builder wants. The inverse of the encoder used
695
+ /// for `shielded_address`: Bech32m payload is a type byte followed by the 43
696
+ /// address bytes.
697
+ fn decode_shielded_address(address: &str, network: &str) -> WalletResult<[u8; 43]> {
698
+ let (hrp, data) = bech32::decode(address).map_err(|e| e.to_string())?;
699
+ let expected = hrp_for(network)?;
700
+ if hrp.as_str() != expected.as_str() {
701
+ return Err(format!(
702
+ "address is for a different network: expected {}, got {}",
703
+ expected.as_str(),
704
+ hrp.as_str()
705
+ ));
706
+ }
707
+ if data.len() != 44 || data[0] != ORCHARD_TYPE {
708
+ return Err("not an Orchard shielded address".into());
709
+ }
710
+ let mut raw = [0u8; 43];
711
+ raw.copy_from_slice(&data[1..]);
712
+ Ok(raw)
713
+ }
714
+
715
+ /// The fee the network charges for a shielded transfer, in credits. A transfer
716
+ /// spends up to two notes and creates two (recipient + change), which is the
717
+ /// same action count the wallet reserves against, so quoting it here matches
718
+ /// what the builder will charge.
719
+ fn transfer_fee_credits() -> WalletResult<u64> {
720
+ use dash_sdk::dpp::version::PlatformVersion;
721
+ dash_sdk::dpp::shielded::compute_minimum_shielded_fee(
722
+ TRANSFER_ACTIONS,
723
+ PlatformVersion::latest(),
724
+ )
725
+ .map_err(|e| e.to_string())
726
+ }
727
+
728
+ /// Orchard actions in a two-in / two-out transfer bundle.
729
+ const TRANSFER_ACTIONS: usize = 2;
730
+
731
+ pub async fn propose_transfer(
732
+ alias: String,
733
+ amount_credits: String,
734
+ to_address: String,
735
+ memo: Option<String>,
736
+ ) -> WalletResult<String> {
737
+ let memo = memo.unwrap_or_default();
738
+ if memo.len() > 32 {
739
+ return Err("memo exceeds 32 UTF-8 bytes".into());
740
+ }
741
+ let amount: u64 = amount_credits
742
+ .parse()
743
+ .map_err(|_| "amount must be a whole number of credits".to_string())?;
744
+ if amount == 0 {
745
+ return Err("amount must be greater than zero".into());
746
+ }
747
+
748
+ let mut clients = CLIENTS.lock().await;
749
+ let slot = clients.get_mut(&alias).ok_or("unknown alias")?;
750
+
751
+ // Reject a wrong-network or malformed recipient here rather than after the
752
+ // caller has signed off on a fee.
753
+ decode_shielded_address(&to_address, &slot.network)?;
754
+
755
+ let fee = transfer_fee_credits()?;
756
+ let available: u64 = slot.available_credits.parse().unwrap_or(0);
757
+ if available < amount.saturating_add(fee) {
758
+ return Err(format!(
759
+ "insufficient funds: {available} credits available, need {} plus {fee} fee",
760
+ amount
761
+ ));
762
+ }
763
+
764
+ let proposal_id = format!("p-{}", slot.proposals.len() + 1);
765
+ slot.proposals.insert(
766
+ proposal_id.clone(),
767
+ PendingProposal {
768
+ to_address,
769
+ amount_credits,
770
+ memo,
771
+ },
772
+ );
773
+ Ok(json::object! {
774
+ proposalId: proposal_id,
775
+ feeCredits: fee.to_string()
776
+ }
777
+ .dump())
778
+ }
779
+
780
+ pub async fn create_transfer(
781
+ alias: String,
782
+ proposal_id: String,
783
+ mnemonic_seed: String,
784
+ ) -> WalletResult<String> {
785
+ // Take the proposal and everything else needed off the slot, then drop the
786
+ // lock: proving and broadcast take tens of seconds, and the sync loop needs
787
+ // this mutex the whole time.
788
+ let (proposal, wallet, manager, account, network) = {
789
+ let mut clients = CLIENTS.lock().await;
790
+ let slot = clients.get_mut(&alias).ok_or("unknown alias")?;
791
+ let proposal = slot
792
+ .proposals
793
+ .remove(&proposal_id)
794
+ .ok_or("unknown proposal")?;
795
+ let wallet = slot.wallet.clone().ok_or("wallet not initialized")?;
796
+ let manager = slot.manager.clone().ok_or("wallet not initialized")?;
797
+ (proposal, wallet, manager, slot.account, slot.network.clone())
798
+ };
799
+
800
+ let recipient = decode_shielded_address(&proposal.to_address, &network)?;
801
+ let amount: u64 = proposal
802
+ .amount_credits
803
+ .parse()
804
+ .map_err(|_| "amount must be a whole number of credits".to_string())?;
805
+
806
+ // The spend authority is re-derived from the mnemonic for this call only.
807
+ // Same 64-byte BIP39 seed the viewing keys were bound with — truncating it
808
+ // would derive a different, empty wallet.
809
+ let mnemonic = <Mnemonic<English>>::from_phrase(mnemonic_seed).map_err(|e| e.to_string())?;
810
+ let seed64 = mnemonic.to_seed("");
811
+
812
+ let coordinator = manager
813
+ .shielded_coordinator()
814
+ .await
815
+ .ok_or("shielded coordinator missing")?;
816
+
817
+ // Memos are a fixed 36 bytes on the wire, zero-padded.
818
+ let mut memo = [0u8; 36];
819
+ let memo_bytes = proposal.memo.as_bytes();
820
+ memo[..memo_bytes.len()].copy_from_slice(memo_bytes);
821
+
822
+ let subwallet = SubwalletId::new(wallet.wallet_id(), account);
823
+
824
+ // The spend's identity only exists once it is in the activity store, so
825
+ // record what was already there and treat whatever outgoing entry appears
826
+ // next as this transfer's.
827
+ let before: std::collections::HashSet<Vec<u8>> = {
828
+ let store = coordinator.store().read().await;
829
+ store
830
+ .get_activity(subwallet, 0, 500)
831
+ .map(|entries| entries.into_iter().map(|e| e.id.to_vec()).collect())
832
+ .unwrap_or_default()
833
+ };
834
+
835
+ let prover = platform_wallet::wallet::shielded::prover::CachedOrchardProver::new();
836
+ wallet
837
+ .shielded_transfer_to(&coordinator, &seed64[..], account, &recipient, amount, memo, &prover)
838
+ .await
839
+ .map_err(|e| format!("shielded transfer failed: {e}"))?;
840
+
841
+ // `shielded_transfer_to` returns unit: the state transition is accepted by
842
+ // Platform, but the txid comes back through the store. It lands as soon as
843
+ // the operation commits its activity row, so a short poll is enough — the
844
+ // caller needs an id to key the pending transaction on.
845
+ let mut txid = String::new();
846
+ for _ in 0..40 {
847
+ if let Ok(store) = coordinator.store().try_read() {
848
+ if let Ok(entries) = store.get_activity(subwallet, 0, 500) {
849
+ if let Some(entry) = entries.into_iter().find(|e| {
850
+ e.direction == ShieldedDirection::Out && !before.contains(&e.id.to_vec())
851
+ }) {
852
+ txid = hex::encode(entry.id);
853
+ break;
854
+ }
855
+ }
856
+ }
857
+ tokio::time::sleep(std::time::Duration::from_millis(250)).await;
858
+ }
859
+ if txid.is_empty() {
860
+ return Err("transfer submitted but no outgoing activity was recorded".into());
861
+ }
862
+
863
+ let fee = transfer_fee_credits().unwrap_or(0);
864
+ Ok(json::object! {
865
+ txid: txid,
866
+ amountCredits: amount.to_string(),
867
+ feeCredits: fee.to_string(),
868
+ toAddress: proposal.to_address
869
+ }
870
+ .dump())
871
+ }
872
+
873
+ pub async fn warm_up_prover() -> WalletResult<()> {
874
+ // Building the Orchard proving key takes ~30s; do it once, off the path of
875
+ // the first spend.
876
+ platform_wallet::wallet::shielded::prover::CachedOrchardProver::new().warm_up();
877
+ let mut ready = PROVER_READY.lock().map_err(|e| e.to_string())?;
878
+ *ready = true;
879
+ Ok(())
880
+ }
881
+
882
+ pub fn is_prover_ready() -> bool {
883
+ PROVER_READY.lock().map(|g| *g).unwrap_or(false)
884
+ }
885
+
886
+ pub fn generate_mnemonic() -> String {
887
+ Mnemonic::<English>::generate(Count::Words24).to_string()
888
+ }