@chainberry/trust-wallet-core 2.5.1 → 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.
@@ -19,4 +19,10 @@ Pod::Spec.new do |s|
19
19
 
20
20
  s.dependency 'ExpoModulesCore'
21
21
  s.dependency 'TrustWalletCore', '4.1.19'
22
+
23
+ s.test_spec 'ConformanceTests' do |ts|
24
+ ts.source_files = 'ios/ConformanceTests/*.swift'
25
+ ts.dependency 'TrustWalletCore', '4.1.19'
26
+ ts.resources = ['conformance/signing-vectors.json', 'conformance/address-derivation-vectors.json']
27
+ end
22
28
  end
package/README.md CHANGED
@@ -24,6 +24,34 @@ GitHub Packages auth is still needed (via `GITHUB_ACTOR`/`GITHUB_TOKEN` env vars
24
24
 
25
25
  Android biometric gating additionally pulls in `androidx.biometric:biometric:1.1.0`.
26
26
 
27
+ ## Publish
28
+
29
+ This package is consumed by the app via `file:modules/trust-wallet-core` (see the root
30
+ `package.json`), not an npm workspace under `packages/*` — so, unlike
31
+ `@chainberry/expo-wallet-sdk`, there's no `-w` flag to use; run these from inside this directory.
32
+ There's also no build step: this package ships its TypeScript source directly (`main`/`types`
33
+ point at `src/index.ts`), so no `prepublishOnly` rebuild happens either.
34
+
35
+ 1. Bump the version (in `package.json`, or via):
36
+ ```sh
37
+ cd modules/trust-wallet-core
38
+ npm version patch # or minor / major
39
+ ```
40
+ 2. Dry-run first — prints exactly what would be published without touching the registry:
41
+ ```sh
42
+ npm publish --dry-run
43
+ ```
44
+ 3. Publish for real:
45
+ ```sh
46
+ npm publish
47
+ ```
48
+ `publishConfig.access: "public"` in this package's own `package.json` already covers the
49
+ `--access public` flag scoped packages otherwise need.
50
+
51
+ Requires npm registry publish auth (an `_authToken` for `@chainberry`, e.g. via `.npmrc` — not
52
+ committed to git). This is separate from the GitHub Packages auth mentioned above, which is only
53
+ needed for bumping the pinned Android wallet-core artifact, not for publishing this npm package.
54
+
27
55
  ## Usage
28
56
 
29
57
  ```ts
@@ -71,11 +99,11 @@ Both platforms pin **Trust Wallet Core 4.1.19** (`com.trustwallet:wallet-core:4.
71
99
 
72
100
  **Byte-for-byte signing output is verified for 8 of 9 signable chains** in `conformance/signing-vectors.json`, asserted by `src/androidTest/.../SigningConformanceTest.kt`. Each vector calls `ChainSigner.sign()` — the same call path production code uses — with a fixed, deterministic (not necessarily broadcast-valid) unsigned tx. `ton` is verified but _not_ byte-exact-asserted: `signTon()` embeds a wall-clock `expireAt` into the signed payload, so its output legitimately differs every run — the test instead checks the output is a well-formed signed BOC. `bitcoincash` has no signing vector (sending is unsupported, see above).
73
101
 
74
- Both Android suites are CI-gated on every push/PR (`.github/workflows/ci.yml`'s `android-unit-tests` and `android-instrumented-tests` jobs, via `example/` — see that folder's README for why an Expo host app is needed to build this module at all). iOS's CI job is still a structural podspec lint only; see the conformance-suite note below for what's missing to close that gap.
102
+ Both Android suites are CI-gated on every push/PR (`.github/workflows/ci.yml`'s `android-unit-tests` and `android-instrumented-tests` jobs, via `example/` — see that folder's README for why an Expo host app is needed to build this module at all). iOS is CI-gated via the `ios-conformance-tests` job: `expo prebuild` generates the Xcode workspace, `pod install` wires up the `ConformanceTests` test_spec declared in the podspec (CocoaPods includes test_specs automatically — there is no `--include-test-specs` flag), and `xcodebuild test` runs both `AddressDerivationConformanceTests` and `SigningConformanceTests` on an iPhone 16 simulator.
75
103
 
76
104
  **Tron divergence resolved.** iOS's `signTron` used to hand wallet-core the full `rawJson` and return its own reconstruction, diverging from Android's `txId`-only input + manual JSON reassembly — and `TronSigningInput` has no `rawJson` field in the pinned 4.1.19 anyway, so the old iOS code didn't compile. iOS now signs the same way Android does: `TronSigningInput.txID` set to the digest, signature reassembled into `{...tx, signature}` in app code (see `ios/ChainSigning.swift`'s `signTron`). Same key + same digest is guaranteed to produce the same ECDSA signature, so this is a structural fix, not something that needs a device to confirm.
77
105
 
78
- **iOS conformance suites are written but not yet wired into a runnable target.** `ios/ConformanceTests/AddressDerivationConformanceTests.swift` and `SigningConformanceTests.swift` mirror the Android fixtures above (all 10 address-derivation chains, the same 8 signing vectors, including a `testTron()` that asserts the reassembled signature matches Android's byte-for-byte). Neither file is part of the `ConformanceTests/` SwiftPM package (which still only builds the amount-parsing target — these two `import WalletCore`/`import XCTest`, which that dependency-free package deliberately avoids) or of any Xcode project (`ios/` is gitignored, regenerated by `expo prebuild`, so nothing durable lives there). An iOS engineer still needs to add both files to an XCTest target in the host app's workspace that links `WalletCore.xcframework`, run them on a Mac, and promote `conformance/signing-vectors.json`'s `tron` vector off `"verified-android-only"` once that run confirms the signature match. Until then, `ChainKey.coinType` mapping identically to Android's makes address derivation _likely_ correct on iOS, but this hasn't been independently run on-device.
106
+ **iOS conformance suites are wired via CocoaPods `test_spec`.** `ios/ConformanceTests/AddressDerivationConformanceTests.swift` and `SigningConformanceTests.swift` mirror the Android fixtures above (all 10 address-derivation chains, the same 8 signing vectors, including a `testTron()` that asserts the reassembled signature matches Android's byte-for-byte). Both are declared as a `test_spec 'ConformanceTests'` in `ChainberryTrustWalletCoreModule.podspec`, which links them against `TrustWalletCore 4.1.19` and embeds the JSON fixtures as bundle resources. Running `pod install` from `example/ios` creates the `ChainberryTrustWalletCoreModule-ConformanceTests` scheme in the workspace automatically. The `ConformanceTests/` SwiftPM package continues to test only amount-parsing (no WalletCore dependency, runs standalone via `swift test`). The `tron` signing vector remains `"verified-android-only"` in the JSON until the iOS CI job's first green run confirms the signature matches byte-for-byte.
79
107
 
80
108
  ## License
81
109
 
@@ -44,7 +44,7 @@ class AddressDerivationConformanceTest {
44
44
 
45
45
  // Chains with expected addresses confirmed against WalletCore 4.1.19, on a real device/emulator.
46
46
  // ETH / BNB / POL all share CoinType.ETHEREUM (same secp256k1 key, BIP44 m/44'/60'/0'/0/0).
47
- val VERIFIED = mapOf(
47
+ val verified = mapOf(
48
48
  CoinType.ETHEREUM to "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", // ethereum / bnb / polygon
49
49
  CoinType.SMARTCHAIN to "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", // bnb (same key as ethereum)
50
50
  CoinType.BITCOIN to "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu",
@@ -63,13 +63,13 @@ class AddressDerivationConformanceTest {
63
63
 
64
64
  // Steady state is empty — add a chain's CoinType here (and drop it from VERIFIED) only while
65
65
  // actively harvesting a newly-added chain's address for the first time.
66
- val PENDING = emptyList<CoinType>()
66
+ val pending = emptyList<CoinType>()
67
67
  }
68
68
 
69
69
  @Test
70
70
  fun verifiedAddressesMatch() {
71
71
  val wallet = HDWallet(MNEMONIC, "")
72
- for ((coin, expected) in VERIFIED) {
72
+ for ((coin, expected) in verified) {
73
73
  val actual = wallet.getAddressForCoin(coin)
74
74
  assertEquals("address mismatch for coin=${coin.name}", expected, actual)
75
75
  }
@@ -96,7 +96,7 @@ class AddressDerivationConformanceTest {
96
96
  @Test
97
97
  fun printPendingAddressesForVerification() {
98
98
  val wallet = HDWallet(MNEMONIC, "")
99
- val lines = PENDING.map { coin -> " ${coin.name} -> ${wallet.getAddressForCoin(coin)}" }
99
+ val lines = pending.map { coin -> " ${coin.name} -> ${wallet.getAddressForCoin(coin)}" }
100
100
  println(
101
101
  "\n[AddressDerivationConformanceTest] Pending — verify and move to VERIFIED map:\n" +
102
102
  lines.joinToString("\n")
@@ -125,7 +125,7 @@ class SigningConformanceTest {
125
125
  // methodology). "ton" is deliberately absent — signTon() embeds a wall-clock
126
126
  // `expireAt = now + 600s` into the signed payload, so its output is never byte-reproducible
127
127
  // across runs; see tonVectorIsWellFormed() below for what's actually checked instead.
128
- val EXPECTED: Map<String, String> = mapOf(
128
+ val expectedOutputs: Map<String, String> = mapOf(
129
129
  "ethereum" to "0xf86c808504a817c800825208949858effd232b4033e47d90003d41ec34ecaeda94880de0b6b3a76400008026a0b7cca5f69561cd482cec4e692bd2da17d9eb1d6b3a83fe3f7f11d117de99ca97a00ad0dc153460e39b355c226a2744fc2926f84b95b89c954551571f1bbc251478",
130
130
  "polygon" to "0x02f874818980843b9aca008509502f9000825208949858effd232b4033e47d90003d41ec34ecaeda94880de0b6b3a764000080c001a038b5c72ee38aa18607462b80ab0cb170a8d4df1c4090ac49acd41d6b4b0f9acca05bdee390ee8a50ff569f55d408371213df72a3065902b07317289b39788cb434",
131
131
  "bitcoin" to "0100000000010100000000000000000000000000000000000000000000000000000000000000000000000000000000000250c3000000000000160014c0cebcd6c3d3ca8c75dc5ec62ebe55330ef910e2cebd000000000000160014c0cebcd6c3d3ca8c75dc5ec62ebe55330ef910e20247304402201e8f3663c97712bbf662c96df532e701ca9b660b507ab170d7b4ffbd966d4be0022033bd537c7720b1e7be2b7663d1a0adcbe565ff18a25794aec828015529ab80e101210330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c00000000",
@@ -160,7 +160,7 @@ class SigningConformanceTest {
160
160
  fun signedOutputsMatch() {
161
161
  val wallet = HDWallet(MNEMONIC, "")
162
162
  for ((chain, pair) in vectors()) {
163
- val expected = EXPECTED[chain] ?: continue
163
+ val expected = expectedOutputs[chain] ?: continue
164
164
  val (chainKey, unsignedTx) = pair
165
165
  val actual = ChainSigner.sign(chainKey, wallet, unsignedTx, isTestnet = false).signedTx
166
166
  assertEquals("signed output mismatch for chain=$chain", expected, actual)
@@ -13,14 +13,14 @@ import wallet.core.jni.Hash
13
13
  import wallet.core.jni.PrivateKey
14
14
  import wallet.core.jni.SolanaTransaction
15
15
  import wallet.core.jni.TransactionDecoder
16
+ import wallet.core.jni.proto.Aptos
16
17
  import wallet.core.jni.proto.Bitcoin
17
18
  import wallet.core.jni.proto.Common
18
- import wallet.core.jni.proto.Aptos
19
19
  import wallet.core.jni.proto.Cosmos
20
- import wallet.core.jni.proto.Tezos
21
20
  import wallet.core.jni.proto.Ethereum
22
21
  import wallet.core.jni.proto.Ripple
23
22
  import wallet.core.jni.proto.Solana
23
+ import wallet.core.jni.proto.Tezos
24
24
  import wallet.core.jni.proto.TheOpenNetwork
25
25
  import wallet.core.jni.proto.Tron
26
26
  import java.math.BigInteger
@@ -687,10 +687,10 @@ internal fun ChainSigner.buildSummary(chain: ChainKey, unsignedTx: Map<String, A
687
687
  val firstContract = (unsignedTx["raw_data"] as? Map<String, Any>)
688
688
  ?.let { (it["contract"] as? List<Map<String, Any>>)?.firstOrNull() }
689
689
  firstContract?.let { contract ->
690
- val type_ = contract["type"] as? String ?: ""
690
+ val contractType = contract["type"] as? String ?: ""
691
691
  val value = (contract["parameter"] as? Map<String, Any>)
692
692
  ?.let { it["value"] as? Map<String, Any> }
693
- when (type_) {
693
+ when (contractType) {
694
694
  "TransferContract" -> value?.let { v ->
695
695
  (v["to_address"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
696
696
  (v["amount"] as? Number)?.let { lines += "Amount: ${fmtAmt(it.toDouble() / 1e6)} TRX" }
@@ -710,21 +710,20 @@ internal fun ChainSigner.buildSummary(chain: ChainKey, unsignedTx: Map<String, A
710
710
  lines += "Contract call: ${stripped.length / 2} bytes — review carefully"
711
711
  }
712
712
  }
713
- else -> if (type_.isNotEmpty()) lines += "Contract type: $type_ — review carefully"
714
- }
715
- }
716
- (unsignedTx["txID"] as? String)?.let { txId ->
717
- val rawHex = unsignedTx["raw_data_hex"] as? String
718
- if (rawHex != null) {
719
- val computed = java.security.MessageDigest.getInstance("SHA-256")
720
- .digest(rawHex.hexToBytes()).toHex()
721
- if (computed.lowercase() != txId.lowercase())
722
- throw ChainSigningException("TRX txID does not match SHA256(raw_data_hex) — signing refused")
723
- lines += "TxID verified ✓"
724
- } else {
725
- lines += "TxID: ${txId.take(16)}… (raw_data_hex absent — unverified)"
713
+ else -> if (contractType.isNotEmpty()) lines += "Contract type: $contractType — review carefully"
726
714
  }
727
715
  }
716
+ // Verify txID == SHA256(raw_data_hex). Both fields must be present — fail closed if either
717
+ // is absent so a JS caller cannot suppress the integrity check by omitting raw_data_hex.
718
+ val txId = unsignedTx["txID"] as? String
719
+ ?: throw ChainSigningException("TRX txID missing — signing refused")
720
+ val rawHex = unsignedTx["raw_data_hex"] as? String
721
+ ?: throw ChainSigningException("TRX raw_data_hex missing — cannot verify txID, signing refused")
722
+ val computed = java.security.MessageDigest.getInstance("SHA-256")
723
+ .digest(rawHex.hexToBytes()).toHex()
724
+ if (computed.lowercase() != txId.lowercase())
725
+ throw ChainSigningException("TRX txID does not match SHA256(raw_data_hex) — signing refused")
726
+ lines += "TxID verified ✓"
728
727
  }
729
728
  ChainKey.SOLANA -> {
730
729
  val info = (unsignedTx["unsignedTxBase64"] as? String)?.let { decodeSolanaForSummary(it) }
@@ -740,14 +739,28 @@ internal fun ChainSigner.buildSummary(chain: ChainKey, unsignedTx: Map<String, A
740
739
  if (!info.isTransfer) lines += "Non-transfer instruction — review carefully"
741
740
  }
742
741
  }
743
- ChainKey.SOLANA -> lines += "(Solana — details verified by the network)"
744
742
  ChainKey.BITCOINCASH -> {
745
- try {
746
- val descriptor = JSONObject((unsignedTx["unsignedDescriptorJson"] as? String) ?: "")
747
- descriptor.optString("toAddress").takeIf { it.isNotEmpty() }?.let { lines += "To: ${fmtAddr(it)}" }
748
- val sats = descriptor.optLong("sendAmountSats", -1L)
749
- if (sats >= 0) lines += "Amount: ${fmtAmt(sats.toDouble() / 1e8)} BCH"
750
- } catch (_: Exception) {}
743
+ // Fail closed — if descriptor is absent or unparseable we cannot show what will be signed.
744
+ val descriptorJson = unsignedTx["unsignedDescriptorJson"] as? String
745
+ ?: throw ChainSigningException("Cannot decode BCH descriptor signing refused to prevent blind signing")
746
+ val descriptor = try {
747
+ JSONObject(descriptorJson)
748
+ } catch (_: Exception) {
749
+ throw ChainSigningException("Cannot parse BCH descriptor JSON — signing refused")
750
+ }
751
+ descriptor.optString("toAddress").takeIf { it.isNotEmpty() }?.let { lines += "To: ${fmtAddr(it)}" }
752
+ val sendSats = descriptor.optLong("sendAmountSats", -1L)
753
+ if (sendSats >= 0) lines += "Amount: ${fmtAmt(sendSats.toDouble() / 1e8)} BCH"
754
+ descriptor.optString("changeAddress").takeIf { it.isNotEmpty() }?.let { lines += "Change to: ${fmtAddr(it)}" }
755
+ val spb = descriptor.optInt("satsPerByte", -1)
756
+ if (spb >= 0) lines += "Fee rate: $spb sat/vB"
757
+ @Suppress("UNCHECKED_CAST")
758
+ val inputTotal = (unsignedTx["inputs"] as? List<Map<String, Any>>)
759
+ ?.mapNotNull { (it["amountSats"] as? String)?.toLongOrNull() }
760
+ ?.fold(0L, Long::plus) ?: descriptor.optLong("inputTotalSats", 0L)
761
+ val changeSats = descriptor.optLong("changeAmountSats", 0L)
762
+ val totalFee = inputTotal - sendSats.coerceAtLeast(0L) - changeSats
763
+ if (totalFee > 0) lines += "Total fee: ${fmtAmt(totalFee.toDouble() / 1e8)} BCH"
751
764
  }
752
765
  ChainKey.COSMOS -> {
753
766
  (unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
@@ -852,12 +865,16 @@ private fun decodeSolanaForSummary(b64: String): SolanaSummary? {
852
865
  }
853
866
 
854
867
  null
855
- } catch (_: Exception) { null }
868
+ } catch (_: Exception) {
869
+ null
870
+ }
856
871
  }
857
872
 
858
873
  private fun txHexToDouble(hex: String): Double = try {
859
874
  BigInteger(hex.removePrefix("0x").ifEmpty { "0" }, 16).toDouble()
860
- } catch (_: NumberFormatException) { 0.0 }
875
+ } catch (_: NumberFormatException) {
876
+ 0.0
877
+ }
861
878
 
862
879
  private fun fmtAmt(value: Double): String =
863
880
  "%.8f".format(value).trimEnd('0').trimEnd('.')
@@ -7,8 +7,8 @@ import expo.modules.kotlin.exception.CodedException
7
7
  import expo.modules.kotlin.functions.Coroutine
8
8
  import expo.modules.kotlin.modules.Module
9
9
  import expo.modules.kotlin.modules.ModuleDefinition
10
- import kotlinx.coroutines.sync.Mutex
11
10
  import kotlinx.coroutines.suspendCancellableCoroutine
11
+ import kotlinx.coroutines.sync.Mutex
12
12
  import wallet.core.jni.HDWallet
13
13
  import java.util.UUID
14
14
  import kotlin.coroutines.resume
@@ -86,19 +86,21 @@ class ChainberryTrustWalletCoreModule : Module() {
86
86
  }
87
87
  }
88
88
 
89
- // strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses }.
89
+ // strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses, isTestnet }.
90
90
  // No BIP-39 passphrase support: signTransaction always reconstructs the wallet with an
91
91
  // empty passphrase, so accepting one here would derive addresses from a seed different
92
92
  // from the one actually used to sign — always pass "" to stay consistent with that.
93
93
  // isTestnet selects the address format for BTC/LTC/BCH (see ChainSigner.addressForChain) —
94
- // 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.
95
97
  AsyncFunction("createWallet") Coroutine { strength: Int, isTestnet: Boolean ->
96
98
  val wallet = HDWallet(strength, "")
97
99
  persistNewWallet(wallet, isTestnet)
98
100
  }
99
101
 
100
102
  // One-time mnemonic exposure from JS, at import only — never retained after this call.
101
- // Returns { walletId, addresses }. No BIP-39 passphrase support (see `createWallet`).
103
+ // Returns { walletId, addresses, isTestnet }. No BIP-39 passphrase support (see `createWallet`).
102
104
  AsyncFunction("importWallet") Coroutine { mnemonic: String, isTestnet: Boolean ->
103
105
  val wallet = HDWallet(mnemonic, "") // throws on invalid mnemonic
104
106
  persistNewWallet(wallet, isTestnet)
@@ -106,8 +108,8 @@ class ChainberryTrustWalletCoreModule : Module() {
106
108
 
107
109
  // Reads only the ungated metadata store — no biometric prompt.
108
110
  AsyncFunction("listWallets") {
109
- NativeWalletStore.loadMetadata(context).map { (walletId, addresses) ->
110
- 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)
111
113
  }
112
114
  }
113
115
 
@@ -133,9 +135,11 @@ class ChainberryTrustWalletCoreModule : Module() {
133
135
  }
134
136
 
135
137
  // Triggers the native biometry/device-credential prompt, then signs entirely in-process.
136
- // Returns { signedTx, meta? }. isTestnet must match whatever `createWallet`/`importWallet`
137
- // used see ChainSigner.keyForChain (a mismatch signs with the wrong key for BTC/LTC).
138
- 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> ->
139
143
  val id = NativeWalletStore.validateWalletId(walletId)
140
144
  val chainKey = ChainKey.fromJs(chain)
141
145
  confirmTransaction(chainKey, unsignedTx)
@@ -145,22 +149,21 @@ class ChainberryTrustWalletCoreModule : Module() {
145
149
  // Backfill any addresses missing from metadata (chains added after wallet was created).
146
150
  // Runs silently after the biometric gate — no extra prompt needed.
147
151
  val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
148
- @Suppress("UNCHECKED_CAST")
149
- val storedAddresses = (metadata[id] as? Map<String, String>)?.toMutableMap()
150
- if (storedAddresses != null) {
151
- var changed = false
152
- for (chainEntry in ChainKey.entries) {
153
- val key = chainEntry.name.lowercase()
154
- if (!storedAddresses.containsKey(key)) {
155
- storedAddresses[key] = ChainSigner.addressForChain(wallet, chainEntry, isTestnet)
156
- changed = true
157
- }
158
- }
159
- if (changed) {
160
- metadata[id] = storedAddresses
161
- runCatching { NativeWalletStore.saveMetadata(context, metadata) }
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
162
161
  }
163
162
  }
163
+ if (changed) {
164
+ metadata[id] = record.copy(addresses = storedAddresses)
165
+ runCatching { NativeWalletStore.saveMetadata(context, metadata) }
166
+ }
164
167
  val result = ChainSigner.sign(chainKey, wallet, unsignedTx, isTestnet)
165
168
  val response = mutableMapOf<String, Any>("signedTx" to result.signedTx)
166
169
  result.meta?.let { response["meta"] = it }
@@ -175,8 +178,8 @@ class ChainberryTrustWalletCoreModule : Module() {
175
178
  }
176
179
  }
177
180
 
178
- /// Shows a native AlertDialog with decoded tx details before biometric auth fires.
179
- /// 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.
180
183
  private suspend fun confirmTransaction(chain: ChainKey, unsignedTx: Map<String, Any>) {
181
184
  val message = ChainSigner.buildSummary(chain, unsignedTx)
182
185
  suspendCancellableCoroutine<Unit> { continuation ->
@@ -211,7 +214,7 @@ class ChainberryTrustWalletCoreModule : Module() {
211
214
 
212
215
  try {
213
216
  val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
214
- metadata[walletId] = addresses
217
+ metadata[walletId] = NativeWalletStore.WalletRecord(isTestnet, addresses)
215
218
  NativeWalletStore.saveMetadata(context, metadata)
216
219
  } catch (e: Exception) {
217
220
  // The mnemonic/key is already persisted but has no metadata pointer — compensate by
@@ -222,6 +225,6 @@ class ChainberryTrustWalletCoreModule : Module() {
222
225
  throw e
223
226
  }
224
227
 
225
- return mapOf("walletId" to walletId, "addresses" to addresses)
228
+ return mapOf("walletId" to walletId, "addresses" to addresses, "isTestnet" to isTestnet)
226
229
  }
227
230
  }
@@ -80,8 +80,7 @@ enum class AuthMode(val aliasInfix: String) {
80
80
  * Extends `CodedException` directly (rather than a flat `Exception`) so `.code` survives the
81
81
  * Expo bridge losslessly with no extra wrapping step.
82
82
  */
83
- sealed class NativeWalletStoreError private constructor(code: String, message: String, cause: Throwable? = null) :
84
- CodedException(code, message, cause) {
83
+ sealed class NativeWalletStoreError private constructor(code: String, message: String, cause: Throwable? = null) : CodedException(code, message, cause) {
85
84
 
86
85
  class NotFound(walletId: String) :
87
86
  NativeWalletStoreError("ERR_WALLET_NOT_FOUND", "Wallet not found: $walletId")
@@ -542,17 +541,27 @@ object NativeWalletStore {
542
541
  }
543
542
  }
544
543
 
545
- // 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>)
546
552
 
547
553
  /** Atomically replaces [target] via a temp-file write + `File.renameTo` (an atomic
548
554
  * `rename(2)` on the same filesystem/mount, since the temp file is created alongside
549
555
  * [target] in the same directory) — never a direct in-place overwrite, which could leave a
550
556
  * torn file if the process is killed mid-write. Pure/`File`-based so it's unit-testable on
551
557
  * the plain JVM without an Android `Context`. */
552
- internal fun saveMetadataToFile(target: File, wallets: Map<String, Map<String, String>>) {
558
+ internal fun saveMetadataToFile(target: File, wallets: Map<String, WalletRecord>) {
553
559
  val root = JSONObject()
554
- for ((walletId, addresses) in wallets) {
555
- 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)
556
565
  }
557
566
  val temp = File(target.parentFile, "$METADATA_FILE.tmp-${System.nanoTime()}")
558
567
  try {
@@ -569,32 +578,41 @@ object NativeWalletStore {
569
578
 
570
579
  /** Distinguishes "no metadata has ever been written" (legitimately empty) from a genuine
571
580
  * parse/corruption failure, which now throws a typed [NativeWalletStoreError.Corrupted]
572
- * instead of letting a raw, uncaught `JSONException` leak through the Expo bridge.
573
- * Pure/`File`-based so it's unit-testable on the plain JVM without an Android `Context`. */
574
- 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> {
575
587
  if (!file.exists()) return emptyMap()
576
588
  val root = try {
577
589
  JSONObject(file.readText())
578
590
  } catch (e: JSONException) {
579
591
  throw NativeWalletStoreError.Corrupted("metadata.json is not valid JSON", e)
580
592
  }
581
- val result = mutableMapOf<String, Map<String, String>>()
582
- for (walletId in root.keys()) {
583
- val addressesJson = root.getJSONObject(walletId)
584
- val addresses = mutableMapOf<String, String>()
585
- for (chain in addressesJson.keys()) {
586
- 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)
587
604
  }
588
- result[walletId] = addresses
605
+ } catch (e: JSONException) {
606
+ throw NativeWalletStoreError.Corrupted("metadata.json entry has an unexpected shape", e)
589
607
  }
590
608
  return result
591
609
  }
592
610
 
593
- fun saveMetadata(context: Context, wallets: Map<String, Map<String, String>>) {
611
+ fun saveMetadata(context: Context, wallets: Map<String, WalletRecord>) {
594
612
  saveMetadataToFile(metadataFile(context), wallets)
595
613
  }
596
614
 
597
- fun loadMetadata(context: Context): Map<String, Map<String, String>> {
615
+ fun loadMetadata(context: Context): Map<String, WalletRecord> {
598
616
  return loadMetadataFromFile(metadataFile(context))
599
617
  }
600
618
 
@@ -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) {
@@ -779,21 +779,23 @@ enum ChainSigner {
779
779
  }
780
780
  }
781
781
  }
782
- if let txID = unsignedTx["txID"] as? String {
783
- // Verify txID == SHA256(raw_data_hex) to detect a mismatched digest.
784
- if let rawHex = unsignedTx["raw_data_hex"] as? String,
785
- let rawBytes = hexData(rawHex) {
786
- let computed = Hash.sha256(data: rawBytes)
787
- let computedHex = computed.map { String(format: "%02x", $0) }.joined()
788
- guard computedHex.lowercased() == txID.lowercased() else {
789
- throw Exception(name: "TxIntegrityFailed",
790
- description: "TRX txID does not match SHA256(raw_data_hex) signing refused")
791
- }
792
- lines.append("TxID verified ✓")
793
- } else {
794
- lines.append("TxID: \(txID.prefix(16))… (raw_data_hex absent — unverified)")
795
- }
782
+ // Verify txID == SHA256(raw_data_hex). raw_data_hex must be present — fail closed if absent
783
+ // so a JS caller cannot suppress the integrity check by omitting the field.
784
+ guard let txID = unsignedTx["txID"] as? String else {
785
+ throw Exception(name: "TxIntegrityFailed", description: "TRX txID missing — signing refused")
786
+ }
787
+ guard let rawHex = unsignedTx["raw_data_hex"] as? String,
788
+ let rawBytes = hexData(rawHex) else {
789
+ throw Exception(name: "TxIntegrityFailed",
790
+ description: "TRX raw_data_hex missing cannot verify txID, signing refused")
791
+ }
792
+ let computed = Hash.sha256(data: rawBytes)
793
+ let computedHex = computed.map { String(format: "%02x", $0) }.joined()
794
+ guard computedHex.lowercased() == txID.lowercased() else {
795
+ throw Exception(name: "TxIntegrityFailed",
796
+ description: "TRX txID does not match SHA256(raw_data_hex) — signing refused")
796
797
  }
798
+ lines.append("TxID verified ✓")
797
799
 
798
800
  case .solana:
799
801
  // Decode the pre-built tx to extract recipient and lamports (SOL) or destination and
@@ -812,14 +814,26 @@ enum ChainSigner {
812
814
  }
813
815
 
814
816
  case .bitcoincash:
815
- if let descriptorJson = unsignedTx["unsignedDescriptorJson"] as? String,
816
- let data = descriptorJson.data(using: .utf8),
817
- let descriptor = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
818
- if let to = descriptor["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
819
- if let sats = (descriptor["sendAmountSats"] as? NSNumber)?.int64Value {
820
- lines.append("Amount: \(fmtAmt(Double(sats) / 1e8)) BCH")
821
- }
817
+ // Fail closed — if descriptor is absent or unparseable we cannot show what will be signed.
818
+ guard let descriptorJson = unsignedTx["unsignedDescriptorJson"] as? String,
819
+ let data = descriptorJson.data(using: .utf8),
820
+ let descriptor = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
821
+ throw Exception(name: "UndecodableTx",
822
+ description: "Cannot decode BCH descriptor — signing refused to prevent blind signing")
822
823
  }
824
+ if let to = descriptor["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
825
+ if let sats = (descriptor["sendAmountSats"] as? NSNumber)?.int64Value {
826
+ lines.append("Amount: \(fmtAmt(Double(sats) / 1e8)) BCH")
827
+ }
828
+ if let change = descriptor["changeAddress"] as? String { lines.append("Change to: \(fmtAddr(change))") }
829
+ if let spb = (descriptor["satsPerByte"] as? NSNumber)?.intValue { lines.append("Fee rate: \(spb) sat/vB") }
830
+ let bchInputs = (descriptor["inputs"] as? [[String: Any]])?
831
+ .compactMap { ($0["amountSats"] as? NSNumber)?.int64Value }
832
+ .reduce(Int64(0), +) ?? 0
833
+ let bchSend = (descriptor["sendAmountSats"] as? NSNumber)?.int64Value ?? 0
834
+ let bchChange = (descriptor["changeAmountSats"] as? NSNumber)?.int64Value ?? 0
835
+ let bchFee = bchInputs - bchSend - bchChange
836
+ if bchFee > 0 { lines.append("Total fee: \(fmtAmt(Double(bchFee) / 1e8)) BCH") }
823
837
 
824
838
  case .cosmos:
825
839
  if let to = unsignedTx["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
@@ -19,12 +19,14 @@ public class ChainberryTrustWalletCoreModule: Module {
19
19
  NativeWalletStore.reconcileOrphans()
20
20
  }
21
21
 
22
- // strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses }.
22
+ // strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses, isTestnet }.
23
23
  // No BIP-39 passphrase support: signTransaction always reconstructs the wallet with an
24
24
  // empty passphrase, so accepting one here would derive addresses from a seed different
25
25
  // from the one actually used to sign — always pass "" to stay consistent with that.
26
26
  // isTestnet selects the address format for BTC/LTC/BCH (see ChainSigner.address(for:)) —
27
- // every other chain's address is the same on mainnet and testnet.
27
+ // every other chain's address is the same on mainnet and testnet. This value is persisted
28
+ // as immutable per-wallet metadata (NativeWalletStore.WalletRecord) — signTransaction reads
29
+ // it back from there instead of accepting it as a parameter, so it can never drift.
28
30
  AsyncFunction("createWallet") { (strength: Int, isTestnet: Bool) throws -> [String: Any] in
29
31
  guard let wallet = HDWallet(strength: Int32(strength), passphrase: "") else {
30
32
  throw Exception(name: "WalletError", description: "Failed to generate wallet")
@@ -37,7 +39,7 @@ public class ChainberryTrustWalletCoreModule: Module {
37
39
  }
38
40
 
39
41
  // One-time mnemonic exposure from JS, at import only — never retained after this call.
40
- // Returns { walletId, addresses }. No BIP-39 passphrase support (see `createWallet`).
42
+ // Returns { walletId, addresses, isTestnet }. No BIP-39 passphrase support (see `createWallet`).
41
43
  AsyncFunction("importWallet") { (mnemonic: String, isTestnet: Bool) throws -> [String: Any] in
42
44
  guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else {
43
45
  throw Exception(name: "InvalidMnemonic", description: "Invalid mnemonic phrase")
@@ -52,8 +54,8 @@ public class ChainberryTrustWalletCoreModule: Module {
52
54
  // Reads only the ungated metadata store — no biometric prompt.
53
55
  AsyncFunction("listWallets") { () throws -> [[String: Any]] in
54
56
  do {
55
- return try NativeWalletStore.loadMetadata().map { walletId, addresses in
56
- ["walletId": walletId, "addresses": addresses]
57
+ return try NativeWalletStore.loadMetadata().map { walletId, record in
58
+ ["walletId": walletId, "addresses": record.addresses, "isTestnet": record.isTestnet]
57
59
  }
58
60
  } catch let e as NativeWalletStoreError {
59
61
  throw e.asException
@@ -86,9 +88,11 @@ public class ChainberryTrustWalletCoreModule: Module {
86
88
  }
87
89
 
88
90
  // Triggers the native biometry/passcode prompt, then signs entirely in-process.
89
- // Returns { signedTx, meta? }. isTestnet must match whatever `createWallet`/`importWallet`
90
- // used — see ChainSigner.key(for:) (a mismatch signs with the wrong key for BTC/LTC).
91
- AsyncFunction("signTransaction") { (walletId: String, chain: String, unsignedTx: [String: Any], isTestnet: Bool) async throws -> [String: Any] in
91
+ // Returns { signedTx, meta? }. Network mode (mainnet/testnet) is read from the wallet's own
92
+ // persisted record, not accepted as a parameter — see ChainSigner.key(for:) and
93
+ // NativeWalletStore.WalletRecord for why a caller-supplied value here could sign with the
94
+ // wrong key for BTC/LTC.
95
+ AsyncFunction("signTransaction") { (walletId: String, chain: String, unsignedTx: [String: Any]) async throws -> [String: Any] in
92
96
  do {
93
97
  let id = try NativeWalletStore.validateWalletId(walletId)
94
98
  let chainKey = try ChainKey(fromJs: chain)
@@ -102,19 +106,21 @@ public class ChainberryTrustWalletCoreModule: Module {
102
106
  // (e.g. chains added after the wallet was created). Runs silently after the
103
107
  // biometric gate — no extra prompt needed.
104
108
  var metadata = try NativeWalletStore.loadMetadata()
105
- if var storedAddresses = metadata[id] {
106
- var changed = false
107
- for chain in ChainKey.allCases {
108
- if storedAddresses[chain.rawValue] == nil {
109
- storedAddresses[chain.rawValue] = ChainSigner.address(for: chain, wallet: wallet, isTestnet: isTestnet)
110
- changed = true
111
- }
112
- }
113
- if changed {
114
- metadata[id] = storedAddresses
115
- try? NativeWalletStore.saveMetadata(metadata)
109
+ guard var record = metadata[id] else {
110
+ throw NativeWalletStoreError.notFound(walletId: id)
111
+ }
112
+ let isTestnet = record.isTestnet
113
+ var changed = false
114
+ for chain in ChainKey.allCases {
115
+ if record.addresses[chain.rawValue] == nil {
116
+ record.addresses[chain.rawValue] = ChainSigner.address(for: chain, wallet: wallet, isTestnet: isTestnet)
117
+ changed = true
116
118
  }
117
119
  }
120
+ if changed {
121
+ metadata[id] = record
122
+ try? NativeWalletStore.saveMetadata(metadata)
123
+ }
118
124
  let result = try ChainSigner.sign(chain: chainKey, wallet: wallet, unsignedTx: unsignedTx, isTestnet: isTestnet)
119
125
  var response: [String: Any] = ["signedTx": result.signedTx]
120
126
  if let meta = result.meta { response["meta"] = meta }
@@ -248,7 +254,7 @@ public class ChainberryTrustWalletCoreModule: Module {
248
254
  try NativeWalletStore.saveMnemonic(wallet.mnemonic, walletId: walletId)
249
255
  do {
250
256
  var metadata = try NativeWalletStore.loadMetadata()
251
- metadata[walletId] = addresses
257
+ metadata[walletId] = NativeWalletStore.WalletRecord(isTestnet: isTestnet, addresses: addresses)
252
258
  try NativeWalletStore.saveMetadata(metadata)
253
259
  } catch {
254
260
  // The mnemonic is already persisted but has no metadata pointer — compensate by
@@ -259,7 +265,7 @@ public class ChainberryTrustWalletCoreModule: Module {
259
265
  throw error
260
266
  }
261
267
 
262
- return ["walletId": walletId, "addresses": addresses]
268
+ return ["walletId": walletId, "addresses": addresses, "isTestnet": isTestnet]
263
269
  }
264
270
 
265
271
  /// Prompts biometry-or-device-passcode via `.deviceOwnerAuthentication` (Apple's
@@ -43,13 +43,10 @@ class SigningConformanceTests: XCTestCase {
43
43
  }
44
44
 
45
45
  private func loadFixture() throws -> FixtureFile {
46
- let thisFile = URL(fileURLWithPath: #filePath)
47
- let url = thisFile
48
- .deletingLastPathComponent()
49
- .deletingLastPathComponent()
50
- .deletingLastPathComponent()
51
- .appendingPathComponent("conformance/signing-vectors.json")
52
- .standardizedFileURL
46
+ guard let url = Bundle(for: SigningConformanceTests.self)
47
+ .url(forResource: "signing-vectors", withExtension: "json") else {
48
+ throw XCTSkip("signing-vectors.json not found in test bundle")
49
+ }
53
50
  return try JSONDecoder().decode(FixtureFile.self, from: Data(contentsOf: url))
54
51
  }
55
52
 
@@ -173,15 +173,24 @@ enum NativeWalletStore {
173
173
  }
174
174
  }
175
175
 
176
- // MARK: - Metadata (ungated: walletId -> { chain: address })
176
+ // MARK: - Metadata (ungated: walletId -> { isTestnet, addresses: { chain: address } })
177
+
178
+ /// A wallet's network mode is fixed at creation time (`ChainberryTrustWalletCoreModule
179
+ /// .persistNewWallet`) and never changes thereafter — `isTestnet` is the authoritative value
180
+ /// `signTransaction` must derive/sign with, replacing the old pattern of accepting it fresh as
181
+ /// a parameter on every call.
182
+ struct WalletRecord: Codable {
183
+ var isTestnet: Bool
184
+ var addresses: [String: String]
185
+ }
177
186
 
178
187
  /// Atomically replaces the metadata blob via `SecItemUpdate` when it already exists,
179
188
  /// falling back to `SecItemAdd` only on the very first write. Unlike the mnemonic item,
180
189
  /// the metadata item carries no `SecAccessControl`, so there's no access-control-change
181
190
  /// obstacle to updating in place — this removes the delete-then-add race window entirely
182
191
  /// (a crash between delete and add used to be able to lose the index outright).
183
- static func saveMetadata(_ wallets: [String: [String: String]]) throws {
184
- let data = try JSONSerialization.data(withJSONObject: wallets)
192
+ static func saveMetadata(_ wallets: [String: WalletRecord]) throws {
193
+ let data = try JSONEncoder().encode(wallets)
185
194
  let baseQuery: [String: Any] = [
186
195
  kSecClass as String: kSecClassGenericPassword,
187
196
  kSecAttrService as String: metadataService,
@@ -208,8 +217,10 @@ enum NativeWalletStore {
208
217
  /// from a genuine read/corruption failure, which now throws instead of being silently
209
218
  /// masked as an empty map. Masking it was the root cause of the "transient read failure
210
219
  /// followed by createWallet overwrites the index" scenario: a real failure here must abort
211
- /// the caller, not look identical to "no wallets yet".
212
- static func loadMetadata() throws -> [String: [String: String]] {
220
+ /// the caller, not look identical to "no wallets yet". Data written by a pre-migration build
221
+ /// (walletId -> {chain: address} with no isTestnet/addresses wrapper) fails to decode and is
222
+ /// treated the same as any other corruption — there is no legacy-shape fallback.
223
+ static func loadMetadata() throws -> [String: WalletRecord] {
213
224
  let query: [String: Any] = [
214
225
  kSecClass as String: kSecClassGenericPassword,
215
226
  kSecAttrService as String: metadataService,
@@ -224,7 +235,7 @@ enum NativeWalletStore {
224
235
  guard status == errSecSuccess, let data = result as? Data else {
225
236
  throw classify(status)
226
237
  }
227
- guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: [String: String]] else {
238
+ guard let obj = try? JSONDecoder().decode([String: WalletRecord].self, from: data) else {
228
239
  throw NativeWalletStoreError.corrupted("metadata JSON is unreadable")
229
240
  }
230
241
  return obj
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chainberry/trust-wallet-core",
3
- "version": "2.5.1",
3
+ "version": "2.5.2",
4
4
  "description": "Expo native module wrapping Trust Wallet Core for native-only HD wallet custody, address derivation, and transaction signing (Ethereum, BNB, Polygon, Solana, Tron, TON, Bitcoin, Bitcoin Cash, Litecoin, XRP) — mnemonic/private keys never cross the JS bridge",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -31,6 +31,9 @@ export type Chain =
31
31
  export type WalletSummary = {
32
32
  walletId: string;
33
33
  addresses: Record<Chain, string>;
34
+ /** Fixed at creation time and persisted as immutable native wallet metadata — the
35
+ * authoritative source `signTransaction` derives/signs with for this wallet. */
36
+ isTestnet: boolean;
34
37
  };
35
38
 
36
39
  export type SignResult = {
@@ -46,7 +49,9 @@ const TrustWalletCore = requireNativeModule("TrustWalletCore");
46
49
  * would derive addresses from a seed different from the one actually used to sign.
47
50
  * `isTestnet` selects the address format for BTC/LTC/BCH (every other chain's address is
48
51
  * identical on mainnet and testnet) — callers should pass `IS_TESTNET` from
49
- * `@/constants/wallet-env`. */
52
+ * `@/constants/wallet-env`. The value passed here is permanent: it's persisted as immutable
53
+ * per-wallet metadata and later read back by `signTransaction` for this wallet — it cannot be
54
+ * changed or overridden after creation. */
50
55
  export async function createWallet(
51
56
  strength: 128 | 256 = 128,
52
57
  isTestnet = false,
@@ -56,7 +61,7 @@ export async function createWallet(
56
61
 
57
62
  /** One-time mnemonic exposure from the caller — persisted natively immediately, never
58
63
  * retained in JS after this call returns. No BIP-39 passphrase support (see `createWallet`).
59
- * `isTestnet` — see `createWallet`. */
64
+ * `isTestnet` — see `createWallet` (same permanence guarantee applies). */
60
65
  export async function importWallet(
61
66
  mnemonic: string,
62
67
  isTestnet = false,
@@ -74,21 +79,16 @@ export async function deleteWallet(walletId: string): Promise<void> {
74
79
  }
75
80
 
76
81
  /** Triggers the native biometry/passcode prompt, then signs entirely in-process —
77
- * only signed transaction bytes/hex cross back. `isTestnet` must match whatever
78
- * `createWallet`/`importWallet` used for this wallet (see those for why) — pass
79
- * `IS_TESTNET` from `@/constants/wallet-env`. */
82
+ * only signed transaction bytes/hex cross back. Network mode (mainnet/testnet) is not
83
+ * accepted here: the native module reads it from the wallet's own persisted record (set once,
84
+ * at `createWallet`/`importWallet` time), so it can never drift from what created the
85
+ * wallet's addresses. */
80
86
  export async function signTransaction(
81
87
  walletId: string,
82
88
  chain: Chain,
83
89
  unsignedTx: Record<string, unknown>,
84
- isTestnet = false,
85
90
  ): Promise<SignResult> {
86
- return TrustWalletCore.signTransaction(
87
- walletId,
88
- chain,
89
- unsignedTx,
90
- isTestnet,
91
- );
91
+ return TrustWalletCore.signTransaction(walletId, chain, unsignedTx);
92
92
  }
93
93
 
94
94
  /** The one sanctioned mnemonic exposure — explicit backup/reveal flow only, gated behind