Skip to content

Commit f2d049b

Browse files
committed
Backfill pre-birthday Money Merkle leaves and close the native synchronizer on teardown.
1 parent e3a64a1 commit f2d049b

6 files changed

Lines changed: 136 additions & 11 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,7 @@ seed.txt
9191
seed.txt
9292
*.seed
9393
*.mnemonic
94+
95+
# Testnet build & runtime data
96+
app/darkfitestnet/
97+

darkfi-android-sdk/src/main/java/com/nighthawkapps/lib/android/sdk/wallet/DarkfiSynchronizer.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import kotlinx.coroutines.flow.flowOf
1313
* **Chain / tx submission** is expected to flow through **darkfid** JSON-RPC (`DarkfidJsonRpc`) or
1414
* JNI from `drk`; this interface stays transport-agnostic until the native layer lands.
1515
*/
16-
interface DarkfiSynchronizer {
16+
interface DarkfiSynchronizer : java.io.Closeable {
17+
override fun close() {}
18+
1719
val status: Flow<DarkfiSyncStatus>
1820
val processorInfo: Flow<DarkfiProcessorInfo>
1921
val progress: Flow<DarkfiPercent>

darkfi-android-sdk/src/main/java/com/nighthawkapps/lib/android/sdk/wallet/DarkfiWalletCoordinator.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class DarkfiWalletCoordinator internal constructor(
3434
init {
3535
scope.launch(Dispatchers.IO) {
3636
walletFlow.collectLatest { wallet ->
37+
_synchronizer.value?.close()
3738
_synchronizer.value =
3839
wallet?.let { w ->
3940
DarkfiSynchronizerFactory.create(w, appContext, useNativeSynchronizer)
@@ -52,6 +53,7 @@ class DarkfiWalletCoordinator internal constructor(
5253
fun reloadSynchronizer() {
5354
scope.launch(Dispatchers.IO) {
5455
val wallet = persistableWallet.value ?: return@launch
56+
_synchronizer.value?.close()
5557
_synchronizer.value =
5658
DarkfiSynchronizerFactory.create(wallet, appContext, useNativeSynchronizer)
5759
}
@@ -67,6 +69,7 @@ class DarkfiWalletCoordinator internal constructor(
6769
}
6870

6971
fun resetSdk() {
72+
_synchronizer.value?.close()
7073
_synchronizer.value = null
7174
}
7275

darkfi-android-sdk/src/main/java/com/nighthawkapps/lib/android/sdk/wallet/NativeDarkfiSynchronizer.kt

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import com.nighthawkapps.lib.uniffi.darkfi_mobile_ffi.ReorgEvent
1010
import com.nighthawkapps.lib.uniffi.darkfi_mobile_ffi.ReorgEventCallback
1111
import kotlinx.coroutines.CoroutineScope
1212
import kotlinx.coroutines.Dispatchers
13+
import kotlinx.coroutines.Job
1314
import kotlinx.coroutines.SupervisorJob
15+
import kotlinx.coroutines.cancel
1416
import kotlinx.coroutines.delay
1517
import kotlinx.coroutines.flow.Flow
1618
import kotlinx.coroutines.flow.MutableStateFlow
@@ -51,6 +53,7 @@ class NativeDarkfiSynchronizer internal constructor(
5153
private val _fallbackReason = MutableStateFlow("")
5254
private val _fallbackUserMessage = MutableStateFlow("")
5355
private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
56+
private var syncJob: Job? = null
5457

5558
init {
5659
handle.setReorgCallback(
@@ -92,14 +95,11 @@ class NativeDarkfiSynchronizer internal constructor(
9295
override fun applyDarkfidReachability(reachable: Boolean) {
9396
if (reachable) {
9497
_walletErrors.value = null
95-
}
96-
_status.value =
97-
when {
98-
!reachable -> DarkfiSyncStatus.DISCONNECTED
99-
_status.value == DarkfiSyncStatus.SYNCING -> DarkfiSyncStatus.SYNCING
100-
else -> DarkfiSyncStatus.SYNCED
101-
}
102-
if (!reachable) {
98+
// Don't prematurely set SYNCED — delegate to the snapshot so the
99+
// reported status reflects the actual sync state from the FFI layer.
100+
applySyncSnapshotBestEffort()
101+
} else {
102+
_status.value = DarkfiSyncStatus.DISCONNECTED
103103
_walletErrors.value =
104104
DarkfiWalletError.Processor(
105105
IllegalStateException(
@@ -349,14 +349,19 @@ class NativeDarkfiSynchronizer internal constructor(
349349
}
350350

351351
private fun startSyncProgressPolling() {
352-
syncScope.launch {
352+
syncJob = syncScope.launch {
353353
while (isActive) {
354354
applySyncSnapshotBestEffort()
355355
delay(SYNC_PROGRESS_POLL_MS)
356356
}
357357
}
358358
}
359359

360+
override fun close() {
361+
syncJob?.cancel()
362+
syncScope.cancel()
363+
}
364+
360365
private companion object {
361366
const val SYNC_PROGRESS_POLL_MS = 15_000L
362367
}

rust/darkfi-mobile-ffi/src/bootstrap.rs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,25 @@ pub async fn bootstrap_drk(
7878
} else {
7979
seed_birthday_scan_cursor(&drk, birthday).await?;
8080
}
81+
82+
// Backfill the Money Merkle tree with pre-birthday note commitments so
83+
// spend proofs use a root that includes genesis..birthday-1. Without
84+
// this, the local tree only has leaves from birthday..tip and computed
85+
// Merkle roots won't match any valid on-chain anchor.
86+
let pin = pin_from_config(config);
87+
if let Err(e) = backfill_money_tree_to_birthday(
88+
&drk,
89+
birthday,
90+
&config.lightwallet_server_url,
91+
pin,
92+
)
93+
.await
94+
{
95+
tracing::warn!(
96+
target: "wallet-bootstrap",
97+
"Birthday tree backfill skipped (sync will rebuild later): {e}"
98+
);
99+
}
81100
} else if config.birthday_height == 0 {
82101
// Fresh create (birthday 0): jump scan cursor to LWD tip — new wallets
83102
// have no history; walking genesis → tip only causes trial-decrypt /
@@ -168,3 +187,95 @@ fn parse_network(network: &str) -> Network {
168187
_ => Network::Testnet, // testnet + localnet share Testnet address encoding
169188
}
170189
}
190+
191+
/// Stream note commitments from genesis to `birthday - 1` and append them to
192+
/// the Money Merkle tree without trial decryption.
193+
///
194+
/// This ensures that spend proofs generated after a birthday restore use a
195+
/// Merkle root that includes ALL on-chain commitments, not just the ones
196+
/// from `birthday..tip`. Without this backfill, the local tree root diverges
197+
/// from the on-chain anchor and `tx.calculate_fee` / broadcast fails with
198+
/// an invalid anchor error.
199+
///
200+
/// Non-fatal: if LWD is unreachable the sync engine will rebuild the tree
201+
/// on next successful connection (via `rebuild_money_tree_to_height`).
202+
async fn backfill_money_tree_to_birthday(
203+
drk: &Drk,
204+
birthday: u32,
205+
lwd_url: &str,
206+
tls_pin: Option<[u8; 32]>,
207+
) -> Result<(), String> {
208+
use darkfi_sdk::crypto::MerkleNode;
209+
use darkfi_sdk::pasta::group::ff::PrimeField;
210+
use darkfi_sdk::pasta::pallas;
211+
use std::collections::{BTreeMap, HashSet};
212+
213+
let end = birthday.saturating_sub(1);
214+
if end == 0 {
215+
return Ok(());
216+
}
217+
218+
let client = LightwalletClient::from_endpoint_and_pin(lwd_url, tls_pin);
219+
220+
// Collect owned coin bytes so we can mark them in the tree (edge case:
221+
// a restored wallet may have coins discovered by a previous partial sync).
222+
let owned: HashSet<Vec<u8>> = match drk.get_coins(false).await {
223+
Ok(coins) => coins
224+
.into_iter()
225+
.map(|(own, _, _, _, _)| own.coin.to_bytes().to_vec())
226+
.collect(),
227+
Err(_) => HashSet::new(),
228+
};
229+
230+
let mut tree = crate::sync::empty_money_tree();
231+
let mut appended = 0u64;
232+
233+
const CHUNK: u32 = 4096;
234+
let mut start = 1u32;
235+
while start <= end {
236+
let chunk_end = end.min(start.saturating_add(CHUNK.saturating_sub(1)));
237+
let updates = client.get_note_commitments(start, chunk_end).await?;
238+
239+
let mut by_h: BTreeMap<u32, Vec<Vec<u8>>> = BTreeMap::new();
240+
for (height, coins) in updates {
241+
if height >= start && height <= chunk_end {
242+
by_h.entry(height).or_default().extend(coins);
243+
}
244+
}
245+
246+
for height in start..=chunk_end {
247+
for coin_bytes in by_h.get(&height).map(|v| v.as_slice()).unwrap_or(&[]) {
248+
if coin_bytes.len() != 32 {
249+
continue;
250+
}
251+
let mut repr = [0u8; 32];
252+
repr.copy_from_slice(coin_bytes);
253+
let Some(base) = Option::<pallas::Base>::from(pallas::Base::from_repr(repr))
254+
else {
255+
continue;
256+
};
257+
tree.append(MerkleNode::from(base));
258+
appended += 1;
259+
if owned.contains(coin_bytes) {
260+
let _ = tree.mark();
261+
}
262+
}
263+
}
264+
265+
start = chunk_end.saturating_add(1);
266+
if start == 0 {
267+
break; // overflow guard
268+
}
269+
}
270+
271+
drk.cache
272+
.insert_merkle_trees(&[(drk::money::SLED_MERKLE_TREES_MONEY, &tree)])
273+
.map_err(|e| format!("persist backfilled Money tree: {e}"))?;
274+
let _ = drk.cache.sled_db.flush();
275+
276+
tracing::info!(
277+
target: "wallet-bootstrap",
278+
"Birthday backfill complete: appended {appended} pre-birthday commitments (1..={end})"
279+
);
280+
Ok(())
281+
}

rust/darkfi-mobile-ffi/src/sync.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1410,7 +1410,7 @@ pub(crate) fn load_received_memo(drk: &drk::Drk, tx_hash: &str) -> Option<String
14101410
.filter(|s| !s.is_empty())
14111411
}
14121412

1413-
fn empty_money_tree() -> darkfi_sdk::crypto::MerkleTree {
1413+
pub(crate) fn empty_money_tree() -> darkfi_sdk::crypto::MerkleTree {
14141414
use darkfi_sdk::crypto::{MerkleNode, MerkleTree};
14151415
use darkfi_sdk::pasta::group::ff::Field;
14161416
use darkfi_sdk::pasta::pallas;

0 commit comments

Comments
 (0)