@chainberry/trust-wallet-core 2.5.0 → 2.5.2

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.
@@ -1,12 +1,14 @@
1
1
  package com.chainberry.trustwalletcore
2
2
 
3
3
  import android.app.AlertDialog
4
+ import android.util.Log
4
5
  import androidx.fragment.app.FragmentActivity
5
6
  import expo.modules.kotlin.exception.CodedException
6
7
  import expo.modules.kotlin.functions.Coroutine
7
8
  import expo.modules.kotlin.modules.Module
8
9
  import expo.modules.kotlin.modules.ModuleDefinition
9
10
  import kotlinx.coroutines.suspendCancellableCoroutine
11
+ import kotlinx.coroutines.sync.Mutex
10
12
  import wallet.core.jni.HDWallet
11
13
  import java.util.UUID
12
14
  import kotlin.coroutines.resume
@@ -21,6 +23,13 @@ class ChainberryTrustWalletCoreModule : Module() {
21
23
  // Must be loaded once before any JNI calls
22
24
  System.loadLibrary("TrustWalletCore")
23
25
  }
26
+
27
+ // Global, not per-walletId: createWallet/importWallet/deleteWallet all read-modify-write the
28
+ // *entire* metadata map, so two concurrent calls touching different wallet ids would still
29
+ // race each other on that shared blob — a per-id lock wouldn't protect against that. See
30
+ // docs/adr/0002. A single companion-object Mutex (rather than an instance property) keeps
31
+ // this a true process-wide lock even if more than one module instance is ever created.
32
+ private val lifecycleMutex = Mutex()
24
33
  }
25
34
 
26
35
  private val context get() = appContext.reactContext
@@ -30,22 +39,68 @@ class ChainberryTrustWalletCoreModule : Module() {
30
39
  get() = appContext.currentActivity as? FragmentActivity
31
40
  ?: throw CodedException("NoActivity", "No foreground FragmentActivity to host the biometric prompt", null)
32
41
 
42
+ /**
43
+ * Serializes the whole body — including the biometric/device-credential prompt, not just the
44
+ * store writes — against every other lifecycle-mutating call, so at most one is ever touching
45
+ * the shared metadata store at a time (see docs/adr/0002). Also forecloses a second, separate
46
+ * bug: two concurrent `BiometricPrompt` invocations racing each other.
47
+ *
48
+ * [rejectIfBusy] chooses the policy for a caller that finds the lock already held:
49
+ * `createWallet`/`importWallet` reject immediately (`ERR_WALLET_OPERATION_IN_PROGRESS`) so a
50
+ * double-tap can never mint two wallets; `deleteWallet` queues instead, since two distinct
51
+ * deletes are both legitimate and should both eventually happen.
52
+ */
53
+ private suspend fun <T> withLifecycleLock(rejectIfBusy: Boolean, body: suspend () -> T): T {
54
+ if (rejectIfBusy) {
55
+ if (!lifecycleMutex.tryLock()) {
56
+ throw CodedException(
57
+ "ERR_WALLET_OPERATION_IN_PROGRESS",
58
+ "Another wallet operation is already in progress",
59
+ null,
60
+ )
61
+ }
62
+ } else {
63
+ lifecycleMutex.lock()
64
+ }
65
+ try {
66
+ return body()
67
+ } finally {
68
+ lifecycleMutex.unlock()
69
+ }
70
+ }
71
+
33
72
  override fun definition() = ModuleDefinition {
34
73
  Name("TrustWalletCore")
35
74
 
36
- // strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses }.
75
+ // Runs once, right after module init, before any of the AsyncFunctions below can be
76
+ // reached from JS — so nothing can legitimately be mid-operation yet, which is exactly what
77
+ // makes a one-shot pass here sufficient (no lock/grace-period needed against an in-flight
78
+ // call). This is the actual crash-safety mechanism for an interrupted create/delete; see
79
+ // CONTEXT.md and docs/adr/0001. Never allowed to block or crash startup.
80
+ OnCreate {
81
+ val reactContext = appContext.reactContext
82
+ if (reactContext == null) {
83
+ Log.w("TrustWalletCoreModule", "reconciliation: react context unavailable at module init, skipping")
84
+ } else {
85
+ NativeWalletStore.reconcileOrphans(reactContext)
86
+ }
87
+ }
88
+
89
+ // strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses, isTestnet }.
37
90
  // No BIP-39 passphrase support: signTransaction always reconstructs the wallet with an
38
91
  // empty passphrase, so accepting one here would derive addresses from a seed different
39
92
  // from the one actually used to sign — always pass "" to stay consistent with that.
40
93
  // isTestnet selects the address format for BTC/LTC/BCH (see ChainSigner.addressForChain) —
41
- // every other chain's address is the same on mainnet and testnet.
94
+ // every other chain's address is the same on mainnet and testnet. This value is persisted
95
+ // as immutable per-wallet metadata (NativeWalletStore.WalletRecord) — signTransaction reads
96
+ // it back from there instead of accepting it as a parameter, so it can never drift.
42
97
  AsyncFunction("createWallet") Coroutine { strength: Int, isTestnet: Boolean ->
43
98
  val wallet = HDWallet(strength, "")
44
99
  persistNewWallet(wallet, isTestnet)
45
100
  }
46
101
 
47
102
  // One-time mnemonic exposure from JS, at import only — never retained after this call.
48
- // Returns { walletId, addresses }. No BIP-39 passphrase support (see `createWallet`).
103
+ // Returns { walletId, addresses, isTestnet }. No BIP-39 passphrase support (see `createWallet`).
49
104
  AsyncFunction("importWallet") Coroutine { mnemonic: String, isTestnet: Boolean ->
50
105
  val wallet = HDWallet(mnemonic, "") // throws on invalid mnemonic
51
106
  persistNewWallet(wallet, isTestnet)
@@ -53,8 +108,8 @@ class ChainberryTrustWalletCoreModule : Module() {
53
108
 
54
109
  // Reads only the ungated metadata store — no biometric prompt.
55
110
  AsyncFunction("listWallets") {
56
- NativeWalletStore.loadMetadata(context).map { (walletId, addresses) ->
57
- mapOf("walletId" to walletId, "addresses" to addresses)
111
+ NativeWalletStore.loadMetadata(context).map { (walletId, record) ->
112
+ mapOf("walletId" to walletId, "addresses" to record.addresses, "isTestnet" to record.isTestnet)
58
113
  }
59
114
  }
60
115
 
@@ -62,25 +117,53 @@ class ChainberryTrustWalletCoreModule : Module() {
62
117
  // anything is deleted, same gate as `signTransaction`/`exportMnemonic`. A
63
118
  // compromised/malicious JS caller can still invoke this directly (there's no UI call
64
119
  // site today), so the gate must live here rather than in JS.
120
+ //
121
+ // Removes the metadata entry *before* the secret (mnemonic file + Keystore key) — the
122
+ // reverse of the old ordering. If this is interrupted between the two steps, the wallet is
123
+ // already gone from `listWallets` and only an orphaned secret-side resource is left behind,
124
+ // which the next app launch's reconciliation pass cleans up (see docs/adr/0001) — never a
125
+ // metadata record still pointing at a secret that's already gone.
65
126
  AsyncFunction("deleteWallet") Coroutine { walletId: String ->
66
- val id = NativeWalletStore.validateWalletId(walletId)
67
- NativeWalletStore.confirmIdentity(activity, context, "Delete wallet")
68
- NativeWalletStore.deleteMnemonic(context, id)
69
- val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
70
- metadata.remove(id)
71
- NativeWalletStore.saveMetadata(context, metadata)
127
+ withLifecycleLock(rejectIfBusy = false) {
128
+ val id = NativeWalletStore.validateWalletId(walletId)
129
+ NativeWalletStore.confirmIdentity(activity, context, "Delete wallet")
130
+ val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
131
+ metadata.remove(id)
132
+ NativeWalletStore.saveMetadata(context, metadata)
133
+ NativeWalletStore.deleteMnemonic(context, id)
134
+ }
72
135
  }
73
136
 
74
137
  // Triggers the native biometry/device-credential prompt, then signs entirely in-process.
75
- // Returns { signedTx, meta? }. isTestnet must match whatever `createWallet`/`importWallet`
76
- // used see ChainSigner.keyForChain (a mismatch signs with the wrong key for BTC/LTC).
77
- AsyncFunction("signTransaction") Coroutine { walletId: String, chain: String, unsignedTx: Map<String, Any>, isTestnet: Boolean ->
138
+ // Returns { signedTx, meta? }. Network mode (mainnet/testnet) is read from the wallet's own
139
+ // persisted record, not accepted as a parameter see ChainSigner.keyForChain and
140
+ // NativeWalletStore.WalletRecord for why a caller-supplied value here could sign with the
141
+ // wrong key for BTC/LTC.
142
+ AsyncFunction("signTransaction") Coroutine { walletId: String, chain: String, unsignedTx: Map<String, Any> ->
78
143
  val id = NativeWalletStore.validateWalletId(walletId)
79
144
  val chainKey = ChainKey.fromJs(chain)
80
145
  confirmTransaction(chainKey, unsignedTx)
81
146
  val cipher = NativeWalletStore.authenticateForExistingWallet(activity, context, id, "Sign transaction")
82
147
  val mnemonic = NativeWalletStore.loadMnemonic(context, id, cipher)
83
148
  val wallet = HDWallet(mnemonic, "")
149
+ // Backfill any addresses missing from metadata (chains added after wallet was created).
150
+ // Runs silently after the biometric gate — no extra prompt needed.
151
+ val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
152
+ val record = metadata[id] ?: throw NativeWalletStoreError.NotFound(id)
153
+ val isTestnet = record.isTestnet
154
+ val storedAddresses = record.addresses.toMutableMap()
155
+ var changed = false
156
+ for (chainEntry in ChainKey.entries) {
157
+ val key = chainEntry.name.lowercase()
158
+ if (!storedAddresses.containsKey(key)) {
159
+ storedAddresses[key] = ChainSigner.addressForChain(wallet, chainEntry, isTestnet)
160
+ changed = true
161
+ }
162
+ }
163
+ if (changed) {
164
+ metadata[id] = record.copy(addresses = storedAddresses)
165
+ runCatching { NativeWalletStore.saveMetadata(context, metadata) }
166
+ }
84
167
  val result = ChainSigner.sign(chainKey, wallet, unsignedTx, isTestnet)
85
168
  val response = mutableMapOf<String, Any>("signedTx" to result.signedTx)
86
169
  result.meta?.let { response["meta"] = it }
@@ -95,8 +178,8 @@ class ChainberryTrustWalletCoreModule : Module() {
95
178
  }
96
179
  }
97
180
 
98
- /// Shows a native AlertDialog with decoded tx details before biometric auth fires.
99
- /// The user must tap "Confirm & Sign" — cancelling throws UserCancelled.
181
+ // Shows a native AlertDialog with decoded tx details before biometric auth fires.
182
+ // The user must tap "Confirm & Sign" — cancelling throws UserCancelled.
100
183
  private suspend fun confirmTransaction(chain: ChainKey, unsignedTx: Map<String, Any>) {
101
184
  val message = ChainSigner.buildSummary(chain, unsignedTx)
102
185
  suspendCancellableCoroutine<Unit> { continuation ->
@@ -131,7 +214,7 @@ class ChainberryTrustWalletCoreModule : Module() {
131
214
 
132
215
  try {
133
216
  val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
134
- metadata[walletId] = addresses
217
+ metadata[walletId] = NativeWalletStore.WalletRecord(isTestnet, addresses)
135
218
  NativeWalletStore.saveMetadata(context, metadata)
136
219
  } catch (e: Exception) {
137
220
  // The mnemonic/key is already persisted but has no metadata pointer — compensate by
@@ -142,6 +225,6 @@ class ChainberryTrustWalletCoreModule : Module() {
142
225
  throw e
143
226
  }
144
227
 
145
- return mapOf("walletId" to walletId, "addresses" to addresses)
228
+ return mapOf("walletId" to walletId, "addresses" to addresses, "isTestnet" to isTestnet)
146
229
  }
147
230
  }
@@ -21,6 +21,7 @@ import org.json.JSONException
21
21
  import org.json.JSONObject
22
22
  import java.io.File
23
23
  import java.security.KeyStore
24
+ import java.util.Collections
24
25
  import java.util.UUID
25
26
  import javax.crypto.Cipher
26
27
  import javax.crypto.KeyGenerator
@@ -79,8 +80,7 @@ enum class AuthMode(val aliasInfix: String) {
79
80
  * Extends `CodedException` directly (rather than a flat `Exception`) so `.code` survives the
80
81
  * Expo bridge losslessly with no extra wrapping step.
81
82
  */
82
- sealed class NativeWalletStoreError private constructor(code: String, message: String, cause: Throwable? = null) :
83
- CodedException(code, message, cause) {
83
+ sealed class NativeWalletStoreError private constructor(code: String, message: String, cause: Throwable? = null) : CodedException(code, message, cause) {
84
84
 
85
85
  class NotFound(walletId: String) :
86
86
  NativeWalletStoreError("ERR_WALLET_NOT_FOUND", "Wallet not found: $walletId")
@@ -236,6 +236,22 @@ object NativeWalletStore {
236
236
  * metadata field needed. */
237
237
  internal fun keyAlias(mode: AuthMode, walletId: String): String = KEY_ALIAS_PREFIX + mode.aliasInfix + walletId
238
238
 
239
+ /** Inverse of [keyAlias]: recovers `(mode, walletId)` from a raw Keystore alias string, or
240
+ * `null` if it doesn't match this module's alias scheme at all (some other feature's Keystore
241
+ * entry, sharing the same `AndroidKeyStore` provider). Checks the longer/specific infixes
242
+ * (`bio_`, `cred_`) before the empty [AuthMode.LEGACY_COMBINED] infix, which would otherwise
243
+ * match every alias under [KEY_ALIAS_PREFIX] — relies on [AuthMode.entries] iterating in
244
+ * declaration order, same assumption [resolveExistingMode] makes. Used by [reconcileOrphans]
245
+ * to identify which Keystore aliases belong to which wallet id, without needing a Context. */
246
+ internal fun parseKeyAlias(alias: String): Pair<AuthMode, String>? {
247
+ if (!alias.startsWith(KEY_ALIAS_PREFIX)) return null
248
+ for (mode in AuthMode.entries) {
249
+ val prefix = KEY_ALIAS_PREFIX + mode.aliasInfix
250
+ if (alias.startsWith(prefix)) return mode to alias.removePrefix(prefix)
251
+ }
252
+ return null
253
+ }
254
+
239
255
  private fun getOrCreateKey(walletId: String, mode: AuthMode): SecretKey {
240
256
  val alias = keyAlias(mode, walletId)
241
257
  val ks = keyStore()
@@ -525,17 +541,27 @@ object NativeWalletStore {
525
541
  }
526
542
  }
527
543
 
528
- // MARK: - Metadata (ungated: walletId -> { chain: address })
544
+ // MARK: - Metadata (ungated: walletId -> { isTestnet, addresses: { chain: address } })
545
+
546
+ /** A wallet's network mode is fixed at creation time ([ChainberryTrustWalletCoreModule
547
+ * .persistNewWallet]) and never changes thereafter — [isTestnet] is the authoritative value
548
+ * `signTransaction` must derive/sign with, replacing the old pattern of accepting it fresh as
549
+ * a parameter on every call (see docs/adr and the security remediation this type was
550
+ * introduced for). */
551
+ data class WalletRecord(val isTestnet: Boolean, val addresses: Map<String, String>)
529
552
 
530
553
  /** Atomically replaces [target] via a temp-file write + `File.renameTo` (an atomic
531
554
  * `rename(2)` on the same filesystem/mount, since the temp file is created alongside
532
555
  * [target] in the same directory) — never a direct in-place overwrite, which could leave a
533
556
  * torn file if the process is killed mid-write. Pure/`File`-based so it's unit-testable on
534
557
  * the plain JVM without an Android `Context`. */
535
- internal fun saveMetadataToFile(target: File, wallets: Map<String, Map<String, String>>) {
558
+ internal fun saveMetadataToFile(target: File, wallets: Map<String, WalletRecord>) {
536
559
  val root = JSONObject()
537
- for ((walletId, addresses) in wallets) {
538
- root.put(walletId, JSONObject(addresses as Map<*, *>))
560
+ for ((walletId, record) in wallets) {
561
+ val entry = JSONObject()
562
+ entry.put("isTestnet", record.isTestnet)
563
+ entry.put("addresses", JSONObject(record.addresses as Map<*, *>))
564
+ root.put(walletId, entry)
539
565
  }
540
566
  val temp = File(target.parentFile, "$METADATA_FILE.tmp-${System.nanoTime()}")
541
567
  try {
@@ -552,35 +578,119 @@ object NativeWalletStore {
552
578
 
553
579
  /** Distinguishes "no metadata has ever been written" (legitimately empty) from a genuine
554
580
  * parse/corruption failure, which now throws a typed [NativeWalletStoreError.Corrupted]
555
- * instead of letting a raw, uncaught `JSONException` leak through the Expo bridge.
556
- * Pure/`File`-based so it's unit-testable on the plain JVM without an Android `Context`. */
557
- internal fun loadMetadataFromFile(file: File): Map<String, Map<String, String>> {
581
+ * instead of letting a raw, uncaught `JSONException`/[org.json.JSONException] leak through
582
+ * the Expo bridge. A record missing `isTestnet` or `addresses` (e.g. data written by a
583
+ * pre-migration build under the old flat schema) is treated the same way — corrupted, not
584
+ * silently reinterpreted — there is no legacy-shape fallback. Pure/`File`-based so it's
585
+ * unit-testable on the plain JVM without an Android `Context`. */
586
+ internal fun loadMetadataFromFile(file: File): Map<String, WalletRecord> {
558
587
  if (!file.exists()) return emptyMap()
559
588
  val root = try {
560
589
  JSONObject(file.readText())
561
590
  } catch (e: JSONException) {
562
591
  throw NativeWalletStoreError.Corrupted("metadata.json is not valid JSON", e)
563
592
  }
564
- val result = mutableMapOf<String, Map<String, String>>()
565
- for (walletId in root.keys()) {
566
- val addressesJson = root.getJSONObject(walletId)
567
- val addresses = mutableMapOf<String, String>()
568
- for (chain in addressesJson.keys()) {
569
- addresses[chain] = addressesJson.getString(chain)
593
+ val result = mutableMapOf<String, WalletRecord>()
594
+ try {
595
+ for (walletId in root.keys()) {
596
+ val entry = root.getJSONObject(walletId)
597
+ val isTestnet = entry.getBoolean("isTestnet")
598
+ val addressesJson = entry.getJSONObject("addresses")
599
+ val addresses = mutableMapOf<String, String>()
600
+ for (chain in addressesJson.keys()) {
601
+ addresses[chain] = addressesJson.getString(chain)
602
+ }
603
+ result[walletId] = WalletRecord(isTestnet, addresses)
570
604
  }
571
- result[walletId] = addresses
605
+ } catch (e: JSONException) {
606
+ throw NativeWalletStoreError.Corrupted("metadata.json entry has an unexpected shape", e)
572
607
  }
573
608
  return result
574
609
  }
575
610
 
576
- fun saveMetadata(context: Context, wallets: Map<String, Map<String, String>>) {
611
+ fun saveMetadata(context: Context, wallets: Map<String, WalletRecord>) {
577
612
  saveMetadataToFile(metadataFile(context), wallets)
578
613
  }
579
614
 
580
- fun loadMetadata(context: Context): Map<String, Map<String, String>> {
615
+ fun loadMetadata(context: Context): Map<String, WalletRecord> {
581
616
  return loadMetadataFromFile(metadataFile(context))
582
617
  }
583
618
 
619
+ // MARK: - Reconciliation (see CONTEXT.md "orphan"/"reconciliation pass", docs/adr/0001)
620
+
621
+ /** Every `.enc` file in [walletsDir] whose wallet id has no entry in [liveIds] — the
622
+ * file-side half of an orphan. Pure/`File`-based so it's unit-testable on the plain JVM.
623
+ * Deliberately does not also match `METADATA_FILE` itself: that file has no `.enc` suffix. */
624
+ internal fun findOrphanFiles(walletsDir: File, liveIds: Set<String>): List<File> =
625
+ walletsDir.listFiles { f -> f.name.endsWith(".enc") }
626
+ ?.filter { it.name.removeSuffix(".enc") !in liveIds }
627
+ ?: emptyList()
628
+
629
+ /** Every leftover `metadata.json.tmp-*` file in [walletsDir] — pure litter from a
630
+ * [saveMetadataToFile] interrupted between writing the temp file and renaming it over the
631
+ * target (the rename itself is atomic, so the target is never at risk; only the temp file
632
+ * can be left behind). Not a security or correctness concern, just disk hygiene swept up
633
+ * alongside the real orphan checks since reconciliation is already scanning this directory. */
634
+ internal fun findStaleMetadataTempFiles(walletsDir: File): List<File> =
635
+ walletsDir.listFiles { f -> f.name.startsWith("$METADATA_FILE.tmp-") }?.toList() ?: emptyList()
636
+
637
+ /**
638
+ * Runs once at module init (see `TrustWalletCoreModule`'s `OnCreate`), before the JS layer can
639
+ * issue its first `createWallet`/`importWallet`/`deleteWallet` call — the actual source of
640
+ * crash-safety for an interrupted create or delete, not the in-call rollback in
641
+ * `persistNewWallet`. Android has *two* independently-persistable secret-side resources per
642
+ * wallet — the `.enc` file and its Keystore key alias, since [getOrCreateKey] creates the key
643
+ * before [saveMnemonic] ever writes the file — so both are checked against the metadata store
644
+ * independently, neither gated on the other's presence.
645
+ *
646
+ * Best-effort and never throws: any failure here is logged and skipped rather than propagated,
647
+ * since a broken reconciliation pass must never become "the app won't launch." A metadata entry
648
+ * with no matching secret-side resource (the reverse shape — a "zombie") is deliberately left
649
+ * untouched here; see [NativeWalletStoreError.NotFound] and docs/adr/0001 for why.
650
+ */
651
+ fun reconcileOrphans(context: Context) {
652
+ val liveIds = try {
653
+ loadMetadata(context).keys
654
+ } catch (e: Exception) {
655
+ Log.w(TAG, "reconciliation: failed to load metadata, skipping this pass entirely", e)
656
+ return
657
+ }
658
+
659
+ val dir = walletsDir(context)
660
+
661
+ try {
662
+ for (file in findOrphanFiles(dir, liveIds)) {
663
+ if (!file.delete()) {
664
+ Log.w(TAG, "reconciliation: failed to delete orphaned file ${file.name}")
665
+ }
666
+ }
667
+ } catch (e: Exception) {
668
+ Log.w(TAG, "reconciliation: failed while cleaning up orphaned files", e)
669
+ }
670
+
671
+ try {
672
+ val ks = keyStore()
673
+ for (alias in Collections.list(ks.aliases())) {
674
+ val (_, walletId) = parseKeyAlias(alias) ?: continue
675
+ if (walletId !in liveIds) {
676
+ try {
677
+ ks.deleteEntry(alias)
678
+ } catch (e: Exception) {
679
+ Log.w(TAG, "reconciliation: failed to delete orphaned key alias $alias", e)
680
+ }
681
+ }
682
+ }
683
+ } catch (e: Exception) {
684
+ Log.w(TAG, "reconciliation: failed while enumerating Keystore aliases", e)
685
+ }
686
+
687
+ try {
688
+ findStaleMetadataTempFiles(dir).forEach { it.delete() }
689
+ } catch (e: Exception) {
690
+ Log.w(TAG, "reconciliation: failed while cleaning up stale metadata temp files", e)
691
+ }
692
+ }
693
+
584
694
  // MARK: - Biometric/device-credential prompt
585
695
 
586
696
  /** Authenticates and returns a `Cipher` ready for [saveMnemonic], for a brand-new wallet id.
@@ -69,13 +69,28 @@ class NativeWalletStoreTest {
69
69
  fun metadata_roundTrips() {
70
70
  val file = File(tmp.root, "metadata.json")
71
71
  val wallets = mapOf(
72
- "id-1" to mapOf("ethereum" to "0xabc"),
73
- "id-2" to mapOf("bitcoin" to "bc1abc"),
72
+ "id-1" to NativeWalletStore.WalletRecord(isTestnet = false, addresses = mapOf("ethereum" to "0xabc")),
73
+ "id-2" to NativeWalletStore.WalletRecord(isTestnet = true, addresses = mapOf("bitcoin" to "bc1abc")),
74
74
  )
75
75
  NativeWalletStore.saveMetadataToFile(file, wallets)
76
76
  assertEquals(wallets, NativeWalletStore.loadMetadataFromFile(file))
77
77
  }
78
78
 
79
+ @Test
80
+ fun metadata_legacyFlatShapeThrowsCorrupted_notSilentlyMisread() {
81
+ // Data written by a pre-migration build (walletId -> {chain: address} with no
82
+ // isTestnet/addresses wrapper) must never be silently reinterpreted — there is no
83
+ // migration path, so this must surface as Corrupted, same as any other unexpected shape.
84
+ val file = File(tmp.root, "metadata.json")
85
+ file.writeText("""{"id-1":{"ethereum":"0xabc"}}""")
86
+ try {
87
+ NativeWalletStore.loadMetadataFromFile(file)
88
+ fail("expected Corrupted for legacy flat-shape metadata")
89
+ } catch (e: NativeWalletStoreError.Corrupted) {
90
+ // expected
91
+ }
92
+ }
93
+
79
94
  @Test
80
95
  fun metadata_missingFileIsEmptyMap() {
81
96
  val file = File(tmp.root, "does-not-exist.json")
@@ -99,7 +114,7 @@ class NativeWalletStoreTest {
99
114
  @Test
100
115
  fun metadata_failedWrite_leavesExistingFileUntouched() {
101
116
  val target = File(tmp.root, "metadata.json")
102
- val original = mapOf("id-1" to mapOf("ethereum" to "0xabc"))
117
+ val original = mapOf("id-1" to NativeWalletStore.WalletRecord(isTestnet = false, addresses = mapOf("ethereum" to "0xabc")))
103
118
  NativeWalletStore.saveMetadataToFile(target, original)
104
119
 
105
120
  // Making the parent directory read-only blocks creating the temp file at all (the
@@ -111,7 +126,10 @@ class NativeWalletStoreTest {
111
126
  target.parentFile!!.setWritable(false)
112
127
  try {
113
128
  try {
114
- NativeWalletStore.saveMetadataToFile(target, mapOf("id-2" to mapOf("bitcoin" to "bc1xyz")))
129
+ NativeWalletStore.saveMetadataToFile(
130
+ target,
131
+ mapOf("id-2" to NativeWalletStore.WalletRecord(isTestnet = false, addresses = mapOf("bitcoin" to "bc1xyz"))),
132
+ )
115
133
  // Some filesystems/CI runners ignore setWritable(false) for the owner (e.g. root).
116
134
  // If the write unexpectedly succeeded, there's nothing to assert here.
117
135
  } catch (e: NativeWalletStoreError.PermissionDenied) {
@@ -271,4 +289,74 @@ class NativeWalletStoreTest {
271
289
  fun describeLegacySecurityLevel_notInsideSecureHardwareIsSoftware() {
272
290
  assertEquals("SOFTWARE", NativeWalletStore.describeLegacySecurityLevel(false))
273
291
  }
292
+
293
+ // ─── parseKeyAlias ───────────────────────────────────────────────────────────
294
+
295
+ @Test
296
+ fun parseKeyAlias_isTheInverseOfKeyAlias() {
297
+ val id = UUID.randomUUID().toString()
298
+ for (mode in AuthMode.entries) {
299
+ val alias = NativeWalletStore.keyAlias(mode, id)
300
+ assertEquals(mode to id, NativeWalletStore.parseKeyAlias(alias))
301
+ }
302
+ }
303
+
304
+ @Test
305
+ fun parseKeyAlias_legacyInfixDoesNotSwallowBioOrCredAliases() {
306
+ // Regression check: LEGACY_COMBINED's infix is "", which is a prefix of every alias under
307
+ // KEY_ALIAS_PREFIX — parseKeyAlias must still resolve a bio_/cred_ alias to its actual mode,
308
+ // not fall through to LEGACY_COMBINED just because the empty-infix check would also match.
309
+ val id = UUID.randomUUID().toString()
310
+ assertEquals(
311
+ AuthMode.BIOMETRIC_STRONG to id,
312
+ NativeWalletStore.parseKeyAlias(NativeWalletStore.keyAlias(AuthMode.BIOMETRIC_STRONG, id)),
313
+ )
314
+ assertEquals(
315
+ AuthMode.DEVICE_CREDENTIAL to id,
316
+ NativeWalletStore.parseKeyAlias(NativeWalletStore.keyAlias(AuthMode.DEVICE_CREDENTIAL, id)),
317
+ )
318
+ }
319
+
320
+ @Test
321
+ fun parseKeyAlias_unrelatedAliasReturnsNull() {
322
+ assertEquals(null, NativeWalletStore.parseKeyAlias("some_other_features_key"))
323
+ }
324
+
325
+ // ─── findOrphanFiles / findStaleMetadataTempFiles (reconciliation, docs/adr/0001) ────────────
326
+
327
+ @Test
328
+ fun findOrphanFiles_fileWithNoMetadataEntryIsOrphaned() {
329
+ File(tmp.root, "orphan-id.enc").writeBytes(byteArrayOf(1))
330
+ val orphans = NativeWalletStore.findOrphanFiles(tmp.root, liveIds = emptySet())
331
+ assertEquals(listOf("orphan-id.enc"), orphans.map { it.name })
332
+ }
333
+
334
+ @Test
335
+ fun findOrphanFiles_fileWithMatchingMetadataEntryIsNotOrphaned() {
336
+ File(tmp.root, "live-id.enc").writeBytes(byteArrayOf(1))
337
+ val orphans = NativeWalletStore.findOrphanFiles(tmp.root, liveIds = setOf("live-id"))
338
+ assertTrue(orphans.isEmpty())
339
+ }
340
+
341
+ @Test
342
+ fun findOrphanFiles_ignoresNonEncFiles() {
343
+ // metadata.json itself (and any stray temp file) must never be swept up as a wallet orphan.
344
+ File(tmp.root, "metadata.json").writeText("{}")
345
+ val orphans = NativeWalletStore.findOrphanFiles(tmp.root, liveIds = emptySet())
346
+ assertTrue(orphans.isEmpty())
347
+ }
348
+
349
+ @Test
350
+ fun findStaleMetadataTempFiles_findsLeftoverTempFile() {
351
+ // Simulates a saveMetadataToFile interrupted after the temp write but before the rename.
352
+ File(tmp.root, "metadata.json.tmp-12345").writeText("{}")
353
+ val stale = NativeWalletStore.findStaleMetadataTempFiles(tmp.root)
354
+ assertEquals(listOf("metadata.json.tmp-12345"), stale.map { it.name })
355
+ }
356
+
357
+ @Test
358
+ fun findStaleMetadataTempFiles_ignoresCommittedMetadataFile() {
359
+ File(tmp.root, "metadata.json").writeText("{}")
360
+ assertTrue(NativeWalletStore.findStaleMetadataTempFiles(tmp.root).isEmpty())
361
+ }
274
362
  }