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.
- package/CHANGELOG.md +5 -0
- package/LICENSE +27 -0
- package/README.md +36 -0
- package/android/build.gradle +56 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/app/edge/rndashshielded/RNDashShieldedModule.kt +215 -0
- package/android/src/main/java/app/edge/rndashshielded/RNDashShieldedPackage.kt +16 -0
- package/android/src/main/java/uniffi/dash/dash.kt +1665 -0
- package/android/src/main/jniLibs/arm64-v8a/libdashshielded.so +0 -0
- package/dash-shielded-native.podspec +32 -0
- package/ios/.uniffi-generated +1 -0
- package/ios/EdgeDashClient.swift +88 -0
- package/ios/RNDashShielded.m +83 -0
- package/ios/RNDashShielded.swift +214 -0
- package/ios/dash-shielded-native-Bridging-Header.h +2 -0
- package/ios/dash.swift +1124 -0
- package/ios/libdashshielded.xcframework/Info.plist +47 -0
- package/ios/libdashshielded.xcframework/ios-arm64/Headers/dashFFI.h +696 -0
- package/ios/libdashshielded.xcframework/ios-arm64/Headers/module.modulemap +7 -0
- package/ios/libdashshielded.xcframework/ios-arm64/libdashshielded.a +0 -0
- package/ios/libdashshielded.xcframework/ios-arm64-simulator/Headers/dashFFI.h +696 -0
- package/ios/libdashshielded.xcframework/ios-arm64-simulator/Headers/module.modulemap +7 -0
- package/ios/libdashshielded.xcframework/ios-arm64-simulator/libdashshielded.a +0 -0
- package/lib/load-addon.d.ts +34 -0
- package/lib/node.d.ts +50 -0
- package/lib/react-native.d.ts +31 -0
- package/lib/rndash.rn.js +267 -0
- package/lib/rndash.rn.js.map +1 -0
- package/lib/src/node.js +328 -0
- package/lib/src/node.js.map +1 -0
- package/lib/types.d.ts +73 -0
- package/node.d.ts +1 -0
- package/node.js +2 -0
- package/package.json +105 -0
- package/prebuilds/darwin-arm64/dashshielded.node +0 -0
- package/rust/Cargo.lock +6795 -0
- package/rust/Cargo.toml +63 -0
- package/rust/build.rs +7 -0
- package/rust/rust-toolchain.toml +3 -0
- package/rust/src/dash.udl +90 -0
- package/rust/src/lib.rs +16 -0
- package/rust/src/napi_api.rs +164 -0
- package/rust/src/uniffi_api.rs +116 -0
- package/rust/src/wallet.rs +888 -0
- package/rust/uniffi-bindgen.rs +3 -0
- package/src/load-addon.ts +99 -0
- package/src/node.ts +248 -0
- package/src/react-native.ts +212 -0
- 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
|
+
}
|