Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ Most Platform actions have hard preconditions. Establish these fixtures before s
| CORE-18 | Per-wallet isolation (identities / addresses / balances / shielded) | Core | Thorough | ✅ | multiwallet | Confirm wallet A's identities, addresses, Core/Platform balances and shielded state never surface under wallet B (Room queries filtered by `walletId`). |
| CORE-19 | Send between two on-device wallets | Core | Thorough | ✅ | multiwallet | Normal send from wallet A to wallet B's receive address. B's balance increases after sync. |
| CORE-20 | Concurrent SPV sync across all wallets | Core | Thorough | ✅ | multiwallet | One SPV runtime per network filters every wallet's addresses; `spvProgress` is manager-global. With 2+ wallets, confirm each reaches the tip. |
| CORE-21 | Multiple wallets bound to the shielded pool concurrently | Shielded | Uncommon | ✅ | multiwallet | `platform_wallet_manager_bind_shielded` is per `wallet_id`; the manager syncs all bound wallets. |
| CORE-21 | Multiple wallets bound to the shielded pool concurrently | Shielded | Uncommon | ✅ | multiwallet | `platform_wallet_manager_bind_shielded` is per `wallet_id`; the manager syncs all bound wallets. EVERY loaded wallet is engine-bound automatically at rebind (`AppContainer.rebindWalletScopedServices` → `ShieldedService.bindEngine`); the global Sync tab still mirrors one wallet, but per-wallet Receive/Balance surfaces read per-wallet. |
| CORE-22 | Re-add a previously deleted wallet (same network) | Core | Uncommon | ✅ | multiwallet | After `CORE-17`, re-import the same mnemonic on the same network. Re-derives the same `wallet_id`; must re-discover identities/addresses/balances cleanly. |
| CORE-23 | Re-add a deleted wallet that also exists on another network | Core | Uncommon | ✅ | multiwallet | Same mnemonic on two networks → distinct network-scoped `wallet_id`s. Delete on X, verify Y untouched, re-add on X, confirm both coexist. |

Expand Down Expand Up @@ -246,11 +246,11 @@ Shielded notes/balance/activity have **no read-side FFI** by design — Rust pus
| SH-09 | Prover warm-up / readiness | Shielded | Common | ✅ | | `warmUpShieldedProver` / `shieldedProverIsReady` (~30s Halo2 key build). |
| SH-10 | Seed shielded pool (anonymity set) | Shielded | Uncommon | ✅ | | `SeedShieldedPoolScreen` → `platform_wallet_manager_shielded_seed_pool_notes`. **Devnet/testnet only.** |
| SH-11 | Create identity from shielded pool (Type 20) | Cross | Common | ✅ | | `CreateIdentityScreen` → funding source **Shielded balance** (fixed denominations) → `platform_wallet_manager_shielded_identity_create_from_pool`. |
| SH-12 | Clear shielded state (wipe notes + re-sync) | Shielded | Uncommon | ✅ | | "Clear" button on the Sync tab (`SyncStatusScreen` → `ShieldedService.clearLocalState`). Stops sync, wipes Room rows, zeroes the mirror; bind credentials kept. |
| SH-13 | Display / share your shielded receive address | Shielded | Common | ✅ | read-only | "Receive Dash" sheet → **Shielded** tab (`ReceiveAddressSheet`, shielded tab): QR + `tdash1…`/`dash1…` bech32m address + Copy. |
| SH-14 | Shielded transfer between two on-device wallets | Shielded | Thorough | ✅ | multiwallet | Wallet A's pool → wallet B's shielded address (`SH-05`); copy B's address from Receive → Shielded tab (`SH-13`). Both wallets must be bound + synced. |
| SH-15 | Unshield from A to a Platform address owned by B | Shielded | Uncommon | ✅ | multiwallet | A unshields (`SH-06`) to a Platform address belonging to wallet B. |
| SH-16 | Shielded withdraw from A to B's Core L1 address | Shielded | Uncommon | ✅ | multiwallet, withdrawal | Wallet A's pool → a Core L1 address owned by wallet B (`SH-08`). Verify B's Core balance rises after SPV sync. |
| SH-12 | Clear shielded state (wipe notes + re-sync) | Shielded | Uncommon | ✅ | | "Clear" button on the Sync tab (`SyncStatusScreen` → `ShieldedService.clearLocalState`). Stops sync, wipes EVERY wallet's Room rows (the Rust reset empties the SHARED tree, so any surviving watermark would re-freeze it), zeroes the mirror; bind credentials kept. The eager re-bind covers the whole fleet: the mirror via `bind`, every other loaded wallet via `bindEngine`, so cross-wallet rows (`SH-14/15/16`) work right after an SH-12 run without a relaunch. |
| SH-13 | Display / share your shielded receive address | Shielded | Common | ✅ | read-only | "Receive Dash" sheet → **Shielded** tab (`ReceiveAddressSheet`, shielded tab): QR + `tdash1…`/`dash1…` bech32m address + Copy (resolved per-wallet via `shieldedDefaultAddress`). Grab wallet B's address here for `SH-14`. |
| SH-14 | Shielded transfer between two on-device wallets | Shielded | Thorough | ✅ | multiwallet | Wallet A's pool → wallet B's shielded address (`SH-05`); copy B's address from Receive → Shielded tab (`SH-13`). Both wallets are engine-bound automatically at rebind (no wallet-swap needed); B's shielded balance rises on the next sync pass. |
| SH-15 | Unshield from A to a Platform address owned by B | Shielded | Uncommon | ✅ | multiwallet | A unshields (`SH-06`) to a Platform address belonging to wallet B. Both wallets are engine-bound automatically at rebind, so A can spend from its own pool without a wallet-swap. |
| SH-16 | Shielded withdraw from A to B's Core L1 address | Shielded | Uncommon | ✅ | multiwallet, withdrawal | Wallet A's pool → a Core L1 address owned by wallet B (`SH-08`). Verify B's Core balance rises after SPV sync. Both wallets are engine-bound automatically at rebind. |

### 4.10 DashPay — `Domain=DashPay`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,8 @@ class AppContainer(private val context: Context) {
// Mirrors iOS `PlatformWalletManager.firstWallet` — the
// lexicographically smallest wallet id; the map keys are fixed-width
// lowercase hex, so string order equals byte order.
val wallet = manager.wallets.value.entries.minByOrNull { it.key }?.value
if (wallet == null) {
val walletEntry = manager.wallets.value.entries.minByOrNull { it.key }
if (walletEntry == null) {
// No wallets on the active network: stop the loops and reset the
// per-wallet service surfaces, so the Sync tab shows zeros instead
// of leaking values from another network's wallet.
Expand All @@ -202,28 +202,59 @@ class AppContainer(private val context: Context) {
platformBalanceSyncService.reset()
shieldedService.unbind()
} else {
val wallet = walletEntry.value
try {
// Re-attach the balance-sync UI reflector: a prior no-wallet
// pass reset() it (nulling its manager), and without this the
// Sync tab stays "Not synced yet" with Sync Now a no-op until
// an app restart.
platformBalanceSyncService.configure(manager)
if (!manager.isPlatformAddressSyncRunning()) {
manager.startPlatformAddressSync()
}
// Best-effort like iOS — failures (no mnemonic in the store,
// declined biometric) leave the service unbound; bind() logs
// and doesn't throw. dbPath mirrors iOS's
// `ShieldedService.dbPath(for:)` naming
// (`shielded_tree_<networkName>.sqlite`), rooted in filesDir.
val shieldedDbPath = java.io.File(
context.filesDir,
"shielded_tree_${manager.network.networkName}.sqlite",
).absolutePath
shieldedService.bind(
manager = manager,
walletId = wallet.walletId,
dbPath = java.io.File(
context.filesDir,
"shielded_tree_${manager.network.networkName}.sqlite",
).absolutePath,
dbPath = shieldedDbPath,
)
// Engine-bind every OTHER loaded wallet into the shared
// network-scoped shielded coordinator (← iOS
// rebindWalletScopedServices, the multi-wallet SH-14/15/16
// enabler). The mirror bind above already registered the
// first wallet; this pass registers the rest so a single
// sync pass trial-decrypts against the union of every
// wallet's viewing keys. Each bind is best-effort +
// independent — one wallet's missing mnemonic must not
// block the others — and the pass runs BEFORE the fallible
// sync-start calls below so a start failure cannot leave
// non-mirror wallets unregistered (iOS review lesson).
org.dashfoundation.dashsdk.services.engineBindOtherWallets(
allWalletIds = manager.wallets.value.keys,
mirrorWalletId = walletEntry.key,
) { otherKey ->
manager.wallets.value[otherKey]?.let { other ->
shieldedService.bindEngine(
manager = manager,
walletId = other.walletId,
dbPath = shieldedDbPath,
)
}
}
// Fallible sync-start calls run AFTER the shielded bind +
// engine-bind pass above: if the platform-address running
// check or start throws, control jumps to the catch — and
// starting it earlier would strand the non-mirror wallets'
// shielded coordinators unregistered until a later rebind
// or relaunch (review lesson).
if (!manager.isPlatformAddressSyncRunning()) {
manager.startPlatformAddressSync()
}
if (shieldedService.isAvailable && !manager.isShieldedSyncRunning()) {
manager.startShieldedSync()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package org.dashfoundation.dashsdk.services

/**
* Pure iteration seam for the multi-wallet shielded engine-bind that
* `AppContainer.rebindWalletScopedServices()` (and
* [ShieldedService.clearLocalState]'s recovery pass) perform — port of
* `ShieldedEngineBindPlan.swift`. Extracted so the "engine-bind every
* OTHER loaded wallet" contract can be unit-tested without a configured
* [org.dashfoundation.dashsdk.wallet.PlatformWalletManager] (whose
* [ShieldedService.bindEngine] path calls into JNI and needs a live
* handle).
*
* Invokes [bindEngine] once for every wallet id in [allWalletIds] EXCEPT
* [mirrorWalletId] (the app-level first wallet, which is engine-bound
* separately via [ShieldedService.bind] when the UI mirror is attached).
*
* The per-id action is best-effort and independent: it must not throw
* (each [ShieldedService.bindEngine] already swallows its own errors),
* but even if a caller passes a throwing action — as the tests do to
* simulate one wallet's missing mnemonic — a failure for one id must NOT
* stop the remaining ids from being bound. Each thrown error is caught
* and dropped so the loop always visits every non-mirror wallet.
*
* Order is not significant to the coordinator (it iterates registrations
* each sync tick), so the caller may pass ids in any order. Ids are
* compared with `==` — callers pass the wallet map's hex-string keys,
* where string equality is byte equality.
*/
suspend fun <K> engineBindOtherWallets(
allWalletIds: Collection<K>,
mirrorWalletId: K,
bindEngine: suspend (K) -> Unit,
) {
for (walletId in allWalletIds) {
if (walletId == mirrorWalletId) continue
// Independent + best-effort: one id's failure can't block the rest.
// ShieldedService.bindEngine never throws in production; the catch
// here is belt-and-braces for the test seam and any future throwing
// binder.
try {
bindEngine(walletId)
} catch (_: Exception) {
// Dropped by contract — the binder logs its own failures.
}
}
}
Loading
Loading