diff --git a/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md b/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md index f28a65e607..0dbd23c2c9 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md +++ b/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md @@ -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. | @@ -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` diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt index de268a85f6..7f1bc104d7 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt @@ -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. @@ -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_.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() } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedEngineBindPlan.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedEngineBindPlan.kt new file mode 100644 index 0000000000..32785fe3d0 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedEngineBindPlan.kt @@ -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 engineBindOtherWallets( + allWalletIds: Collection, + 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. + } + } +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedService.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedService.kt index af1c9587d5..1dfb441d1b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedService.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedService.kt @@ -37,6 +37,19 @@ import org.dashfoundation.dashsdk.wallet.WalletSyncEvent * [bind] drives the Rust-side shielded bind (configure + bind, like the * Swift service) and then reflects the loop; start/stop stay on * [PlatformWalletManager]. + * + * **Single UI mirror + multi-engine-bind** (port of the same design in + * `ShieldedService.swift`): the service mirrors exactly ONE wallet — the + * app-level first wallet — for the global Sync-status surface via [bind]. + * [bindEngine] is the additive companion used by + * `AppContainer.rebindWalletScopedServices()` to engine-register EVERY + * OTHER loaded wallet into the same network-scoped coordinator (no mirror + * repoint). A single shielded sync pass then trial-decrypts against the + * union of all wallets' viewing keys and routes note hits to each + * wallet's own persister (SH-14/15/16 cross-wallet flows). Per-wallet + * receive addresses and balances are read on demand + * ([PlatformWalletManager.shieldedDefaultAddress], per-wallet Room + * queries) rather than from this singleton mirror. */ class ShieldedService(private val database: DashDatabase) { @@ -238,6 +251,69 @@ class ShieldedService(private val database: DashDatabase) { } } + /** + * Register [walletId]'s shielded sub-wallet with the Rust coordinator + * WITHOUT repointing this service's display mirror — port of Swift + * `ShieldedService.bindEngine`. + * + * [bind] attaches the single UI mirror (bound wallet, counters, + * subscriptions) to exactly one wallet — the app-level first wallet. + * [bindEngine] is the additive companion: it engine-binds EVERY OTHER + * loaded wallet into the same network-scoped coordinator so a single + * shielded sync pass trial-decrypts against the union of all wallets' + * viewing keys and routes note hits to each wallet's own persister. + * Per-wallet receive addresses and balances are then read on demand + * ([PlatformWalletManager.shieldedDefaultAddress], per-wallet Room + * queries) rather than from this singleton mirror. + * + * Best-effort and independent per wallet: a missing mnemonic / + * declined keystore read for one wallet logs and returns `false` + * without affecting the others or the mirror. Idempotent — safe to + * call every rebind pass ([PlatformWalletManager.configureShielded] + * no-ops on the same path; [PlatformWalletManager.bindShielded] + * replaces that wallet's registration). + * + * No "already bound" fast path on purpose (iOS lesson): the only + * cheap probe, [PlatformWalletManager.shieldedDefaultAddress], + * reflects the wallet-level sub-wallet binding — which SURVIVES + * [PlatformWalletManager.clearShieldedStorage] (Clear drops only the + * coordinator registrations; there is no sub-wallet unbind FFI). + * Skipping on that signal would silently leave post-Clear wallets + * unregistered (sync passes would never scan them again). Coordinator + * registration has no cheap query, so we always re-bind; the mnemonic + * read + ZIP-32 re-derivation is low-millisecond per wallet and + * rebind fires are rare (wallet-set change, network switch, Clear). + * + * @return whether the engine registration succeeded; callers running + * a best-effort fleet pass may ignore it. + */ + suspend fun bindEngine( + manager: PlatformWalletManager, + walletId: ByteArray, + dbPath: String, + accounts: List = listOf(0), + ): Boolean { + if (!isAvailable) return false + val sortedAccounts = accounts.distinct().sorted() + val walletIdHex4 = walletId.take(4).joinToString("") { "%02x".format(it) } + return try { + manager.configureShielded(dbPath) + manager.bindShielded(walletId, sortedAccounts) + android.util.Log.i( + TAG, + "Shielded engine-bound: walletId=$walletIdHex4… accounts=$sortedAccounts", + ) + true + } catch (e: Exception) { + android.util.Log.w( + TAG, + "Shielded engine-bind failed for walletId=$walletIdHex4…: ${e.message}", + e, + ) + false + } + } + /** * Reduce one event for the bound wallet. Per-wallet completion results * accumulate the cumulative counters; a `cooldownSkip` preserves the @@ -323,19 +399,32 @@ class ShieldedService(private val database: DashDatabase) { * the post-clear resync cold-rebuilds from index 0 instead of * gate-skipping every re-downloaded position against a stale tree * (the frozen-tree / zero-notes desync this Clear exists to fix). - * 2. **Room wipe** — delete the wallet's shielded rows (INCLUDING - * `shielded_sync_states`, the per-subwallet watermark; leaving it + * 2. **Room wipe — every wallet on THIS network** (INCLUDING + * `shielded_sync_states`, the per-subwallet watermark; leaving any * would let [bind]'s `restore_for_wallet` re-seed a caught-up * watermark and re-freeze the tree) in one transaction, and zero the - * published counters. + * published counters. Scoped to the active network's wallets + * (`walletDao().getByNetwork`), NOT global: [clearShieldedStorage] + * empties only this network's `shielded_tree_.sqlite`, so + * wiping other networks' rows would drop notes/activity/watermarks + * whose Rust trees are still populated → data loss + Room↔Rust + * divergence on the next network switch. Within this network the wipe + * must cover EVERY wallet (not just the loaded/bound fleet — an + * unloaded same-network wallet's surviving watermark would restore a + * position ahead of the now-empty shared tree on re-bind and + * gate-skip every note, the exact Room↔SQLite watermark-divergence + * freeze this Clear exists to fix). * 3. **Re-bind + restart sync** — [clearShieldedStorage] dropped every * coordinator registration and quiesced the sync loop, so nothing * would resync until the next app relaunch otherwise. Re-run [bind] - * (re-registers the viewing keys on the now-empty coordinator, its - * `restore_for_wallet` finding no watermark → starts at index 0) and - * restart the background loop, so the button alone triggers an - * in-session cold rebuild 0→N. Mirrors Swift's - * "keep bind credentials, re-bind on next sync" intent, done eagerly. + * for the mirror wallet (re-registers the viewing keys on the + * now-empty coordinator, its `restore_for_wallet` finding no + * watermark → starts at index 0), then [bindEngine] every OTHER + * loaded wallet — mirror-bind failure does NOT skip the others + * ([bind] swallows its errors; the fleet pass runs regardless, the + * iOS best-effort lesson) — and restart the background loop, so the + * button alone triggers an in-session cold rebuild 0→N for the + * whole fleet. * * Fail-closed: if the Rust reset throws, the exception propagates and the * Room rows are left intact — the FFI's contract is that the host must @@ -371,11 +460,28 @@ class ShieldedService(private val database: DashDatabase) { mgr.clearShieldedStorage() android.util.Log.i(TAG, "Shielded clear: Rust store reset OK; wiping Room rows") + // Wipe every wallet on THIS network, but NOT other networks'. `mgr` + // is network-scoped and its shielded tree + // (`shielded_tree_.sqlite`) is per-network, so the Rust + // reset above emptied only the ACTIVE network's SHARED tree. A + // global deleteAll* would drop other networks' notes/activity/ + // watermarks while their Rust trees stay populated → Room/Rust + // divergence + data loss on the next network switch. Scope to the + // network via `walletDao().getByNetwork` — NOT `mgr.wallets`, which + // only holds the successfully-loaded fleet: a same-network wallet + // that failed restore still has Room rows, and a surviving watermark + // would restore a position ahead of the now-empty tree on re-bind + // and gate-skip every note (see doc, step 2). + val networkWalletIds = db.walletDao() + .getByNetwork(mgr.network.ffiValue) + .map { it.walletId } db.withTransaction { - db.shieldedDao().deleteActivityByWallet(walletId) - db.shieldedDao().deleteOutgoingNotesByWallet(walletId) - db.shieldedDao().deleteNotesByWallet(walletId) - db.shieldedDao().deleteSyncStatesByWallet(walletId) + for (wid in networkWalletIds) { + db.shieldedDao().deleteActivityByWallet(wid) + db.shieldedDao().deleteOutgoingNotesByWallet(wid) + db.shieldedDao().deleteNotesByWallet(wid) + db.shieldedDao().deleteSyncStatesByWallet(wid) + } } _state.update { it.copy(shieldedBalance = 0, totalScanned = 0, totalNewNotes = 0) } @@ -385,6 +491,21 @@ class ShieldedService(private val database: DashDatabase) { // restore_for_wallet finds no watermark → starts at index 0). if (dbPath != null) { bind(mgr, walletId, dbPath, accounts) + // Fleet recovery: clearShieldedStorage dropped EVERY wallet's + // coordinator registration, and the mirror bind above restores + // only one. Re-register every other loaded wallet so the next + // pass scans the whole fleet (SH-14/15/16 survive an SH-12 run + // without a relaunch). Runs regardless of the mirror bind's + // outcome — bind() swallows its errors, and one wallet's + // failure must not dark the others (iOS best-effort lesson). + engineBindOtherWallets( + allWalletIds = mgr.wallets.value.keys, + mirrorWalletId = walletIdHex, + ) { otherKey -> + mgr.wallets.value[otherKey]?.let { other -> + bindEngine(mgr, other.walletId, dbPath) + } + } val notRunning = runCatching { !mgr.isShieldedSyncRunning() }.getOrDefault(false) if (isAvailable && notRunning) { runCatching { mgr.startShieldedSync() }.onFailure { diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/services/ShieldedEngineBindPlanTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/services/ShieldedEngineBindPlanTest.kt new file mode 100644 index 0000000000..33ded4fba6 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/services/ShieldedEngineBindPlanTest.kt @@ -0,0 +1,116 @@ +package org.dashfoundation.dashsdk.services + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Coverage for [engineBindOtherWallets] — the pure iteration seam behind + * the multi-wallet shielded engine-bind in + * `AppContainer.rebindWalletScopedServices()` (port of iOS + * `ShieldedEngineBindPlanTests`). + * + * The app engine-binds EVERY loaded wallet into the shared network-scoped + * shielded coordinator so a single sync pass trial-decrypts against the + * union of all wallets' viewing keys (SH-14/15/16 cross-wallet flows). + * The mirror wallet (the lexicographically-first) is bound separately via + * `ShieldedService.bind`, so this seam binds every OTHER wallet. + * + * The real `ShieldedService.bindEngine` calls into JNI and needs a + * configured `PlatformWalletManager`, so the loop logic — "visit every + * non-mirror id" and "one id's failure doesn't stop the rest" — is + * factored into the pure seam and tested with a recording action. + */ +class ShieldedEngineBindPlanTest { + + private fun id(byte: Int) = "%02x".format(byte).repeat(32) + + /** + * Every non-mirror wallet is engine-bound exactly once; the mirror is + * skipped (it is bound separately via the UI-mirror path). + */ + @Test + fun bindsEveryWalletExceptMirror() = runTest { + val mirror = id(0x01) + val others = listOf(id(0x02), id(0x03), id(0x04)) + val all = listOf(mirror) + others + + val bound = mutableListOf() + engineBindOtherWallets(all, mirror) { bound.add(it) } + + assertEquals( + "every non-mirror wallet must be engine-bound", + others.toSet(), + bound.toSet(), + ) + assertFalse( + "the mirror wallet is bound separately and must be skipped here", + bound.contains(mirror), + ) + assertEquals( + "each non-mirror wallet must be bound exactly once", + others.size, + bound.size, + ) + } + + /** + * A throwing bind for ONE wallet must not stop the others — the + * production requirement that one wallet's missing mnemonic / declined + * keystore read cannot dark every other wallet's shielded state. + */ + @Test + fun oneFailureDoesNotStopTheRest() = runTest { + val mirror = id(0x01) + val failing = id(0x03) + val all = listOf(mirror, id(0x02), failing, id(0x04)) + + val attempted = mutableListOf() + engineBindOtherWallets(all, mirror) { walletId -> + attempted.add(walletId) + if (walletId == failing) throw IllegalStateException("bind failed") + } + + assertEquals( + "a throwing bind for one wallet must not skip the remaining wallets", + setOf(id(0x02), failing, id(0x04)), + attempted.toSet(), + ) + } + + /** + * A single-wallet device (only the mirror loaded) binds nothing extra — + * the mirror's own engine registration is the UI-mirror path's job. + */ + @Test + fun singleMirrorWalletBindsNothing() = runTest { + val mirror = id(0x01) + + val bound = mutableListOf() + engineBindOtherWallets(listOf(mirror), mirror) { bound.add(it) } + + assertTrue( + "with only the mirror wallet loaded there is nothing else to engine-bind", + bound.isEmpty(), + ) + } + + /** + * The mirror is skipped even when it is not the first element — map key + * sets have no guaranteed order, so the skip must be by value, not + * position. + */ + @Test + fun mirrorSkippedRegardlessOfPosition() = runTest { + val mirror = id(0x05) + val all = listOf(id(0x02), id(0x05), id(0x08)) // mirror in the middle + + val bound = mutableListOf() + engineBindOtherWallets(all, mirror) { bound.add(it) } + + assertEquals(setOf(id(0x02), id(0x08)), bound.toSet()) + assertFalse(bound.contains(mirror)) + } +}