@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.
@@ -10,12 +10,23 @@ public class ChainberryTrustWalletCoreModule: Module {
10
10
  public func definition() -> ModuleDefinition {
11
11
  Name("TrustWalletCore")
12
12
 
13
- // strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses }.
13
+ // Runs once, right after module init, before any of the AsyncFunctions below can be reached
14
+ // from JS — so nothing can legitimately be mid-operation yet, which is exactly what makes a
15
+ // one-shot pass here sufficient (no lock/grace-period needed against an in-flight call). This
16
+ // is the actual crash-safety mechanism for an interrupted create/delete; see CONTEXT.md and
17
+ // docs/adr/0001. `reconcileOrphans` is non-throwing — never allowed to block or crash startup.
18
+ OnCreate {
19
+ NativeWalletStore.reconcileOrphans()
20
+ }
21
+
22
+ // strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses, isTestnet }.
14
23
  // No BIP-39 passphrase support: signTransaction always reconstructs the wallet with an
15
24
  // empty passphrase, so accepting one here would derive addresses from a seed different
16
25
  // from the one actually used to sign — always pass "" to stay consistent with that.
17
26
  // isTestnet selects the address format for BTC/LTC/BCH (see ChainSigner.address(for:)) —
18
- // 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.
19
30
  AsyncFunction("createWallet") { (strength: Int, isTestnet: Bool) throws -> [String: Any] in
20
31
  guard let wallet = HDWallet(strength: Int32(strength), passphrase: "") else {
21
32
  throw Exception(name: "WalletError", description: "Failed to generate wallet")
@@ -28,7 +39,7 @@ public class ChainberryTrustWalletCoreModule: Module {
28
39
  }
29
40
 
30
41
  // One-time mnemonic exposure from JS, at import only — never retained after this call.
31
- // Returns { walletId, addresses }. No BIP-39 passphrase support (see `createWallet`).
42
+ // Returns { walletId, addresses, isTestnet }. No BIP-39 passphrase support (see `createWallet`).
32
43
  AsyncFunction("importWallet") { (mnemonic: String, isTestnet: Bool) throws -> [String: Any] in
33
44
  guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else {
34
45
  throw Exception(name: "InvalidMnemonic", description: "Invalid mnemonic phrase")
@@ -43,8 +54,8 @@ public class ChainberryTrustWalletCoreModule: Module {
43
54
  // Reads only the ungated metadata store — no biometric prompt.
44
55
  AsyncFunction("listWallets") { () throws -> [[String: Any]] in
45
56
  do {
46
- return try NativeWalletStore.loadMetadata().map { walletId, addresses in
47
- ["walletId": walletId, "addresses": addresses]
57
+ return try NativeWalletStore.loadMetadata().map { walletId, record in
58
+ ["walletId": walletId, "addresses": record.addresses, "isTestnet": record.isTestnet]
48
59
  }
49
60
  } catch let e as NativeWalletStoreError {
50
61
  throw e.asException
@@ -55,23 +66,33 @@ public class ChainberryTrustWalletCoreModule: Module {
55
66
  // deleted, same gate as `signTransaction`/`exportMnemonic`. A compromised/malicious JS
56
67
  // caller can still invoke this directly (there's no UI call site today), so the gate
57
68
  // must live here rather than in JS.
69
+ //
70
+ // Removes the metadata entry *before* the secret (Keychain item) — the reverse of the old
71
+ // ordering. If this is interrupted between the two steps, the wallet is already gone from
72
+ // `listWallets` and only an orphaned Keychain item is left behind, which the next app
73
+ // launch's reconciliation pass cleans up (see docs/adr/0001) — never a metadata record still
74
+ // pointing at a secret that's already gone.
58
75
  AsyncFunction("deleteWallet") { (walletId: String) async throws -> Void in
59
- do {
60
- let id = try NativeWalletStore.validateWalletId(walletId)
61
- _ = try await Self.authenticatedContext(reason: "Delete wallet")
62
- try NativeWalletStore.deleteMnemonic(walletId: id)
63
- var metadata = try NativeWalletStore.loadMetadata()
64
- metadata.removeValue(forKey: id)
65
- try NativeWalletStore.saveMetadata(metadata)
66
- } catch let e as NativeWalletStoreError {
67
- throw e.asException
76
+ try await Self.withLifecycleLock(rejectIfBusy: false) {
77
+ do {
78
+ let id = try NativeWalletStore.validateWalletId(walletId)
79
+ _ = try await Self.authenticatedContext(reason: "Delete wallet")
80
+ var metadata = try NativeWalletStore.loadMetadata()
81
+ metadata.removeValue(forKey: id)
82
+ try NativeWalletStore.saveMetadata(metadata)
83
+ try NativeWalletStore.deleteMnemonic(walletId: id)
84
+ } catch let e as NativeWalletStoreError {
85
+ throw e.asException
86
+ }
68
87
  }
69
88
  }
70
89
 
71
90
  // Triggers the native biometry/passcode prompt, then signs entirely in-process.
72
- // Returns { signedTx, meta? }. isTestnet must match whatever `createWallet`/`importWallet`
73
- // used — see ChainSigner.key(for:) (a mismatch signs with the wrong key for BTC/LTC).
74
- 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
75
96
  do {
76
97
  let id = try NativeWalletStore.validateWalletId(walletId)
77
98
  let chainKey = try ChainKey(fromJs: chain)
@@ -81,6 +102,25 @@ public class ChainberryTrustWalletCoreModule: Module {
81
102
  guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else {
82
103
  throw Exception(name: "InvalidMnemonic", description: "Stored mnemonic failed validation")
83
104
  }
105
+ // Backfill any addresses that were missing when the wallet was first stored
106
+ // (e.g. chains added after the wallet was created). Runs silently after the
107
+ // biometric gate — no extra prompt needed.
108
+ var metadata = try NativeWalletStore.loadMetadata()
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
118
+ }
119
+ }
120
+ if changed {
121
+ metadata[id] = record
122
+ try? NativeWalletStore.saveMetadata(metadata)
123
+ }
84
124
  let result = try ChainSigner.sign(chain: chainKey, wallet: wallet, unsignedTx: unsignedTx, isTestnet: isTestnet)
85
125
  var response: [String: Any] = ["signedTx": result.signedTx]
86
126
  if let meta = result.meta { response["meta"] = meta }
@@ -102,13 +142,84 @@ public class ChainberryTrustWalletCoreModule: Module {
102
142
  }
103
143
  }
104
144
 
145
+ // MARK: - Lifecycle serialization (see docs/adr/0002)
146
+
147
+ /// Serializes create/import/delete against each other — a `Task`-based actor rather than
148
+ /// `NSLock`, since these calls `await` across the biometric prompt and holding an `NSLock`
149
+ /// across a suspension point (where Swift Concurrency may resume on a different underlying
150
+ /// thread) is unsafe.
151
+ private actor LifecycleLock {
152
+ private var locked = false
153
+ private var waiters: [CheckedContinuation<Void, Never>] = []
154
+
155
+ /// Non-blocking: returns `false` immediately if already held (used by create/import, which
156
+ /// reject rather than queue).
157
+ func tryAcquire() -> Bool {
158
+ guard !locked else { return false }
159
+ locked = true
160
+ return true
161
+ }
162
+
163
+ /// Blocking: waits until the lock is free, then acquires it (used by delete, which queues).
164
+ func acquire() async {
165
+ guard locked else {
166
+ locked = true
167
+ return
168
+ }
169
+ await withCheckedContinuation { waiters.append($0) }
170
+ }
171
+
172
+ /// Hands ownership directly to the next waiter rather than freeing the lock and letting
173
+ /// every waiter race a fresh `tryAcquire`/`acquire`.
174
+ func release() {
175
+ if !waiters.isEmpty {
176
+ waiters.removeFirst().resume()
177
+ } else {
178
+ locked = false
179
+ }
180
+ }
181
+ }
182
+
183
+ private static let lifecycleLock = LifecycleLock()
184
+
185
+ /// Serializes `body` — including the biometric/passcode prompt, not just the store writes —
186
+ /// against every other lifecycle-mutating call, so at most one is ever touching the shared
187
+ /// metadata store at a time (see docs/adr/0002). Also forecloses a second, separate bug: two
188
+ /// concurrent `LAContext` evaluations racing each other.
189
+ ///
190
+ /// `rejectIfBusy` chooses the policy for a caller that finds the lock already held:
191
+ /// `createWallet`/`importWallet` reject immediately (`ERR_WALLET_OPERATION_IN_PROGRESS`) so a
192
+ /// double-tap can never mint two wallets; `deleteWallet` queues instead, since two distinct
193
+ /// deletes are both legitimate and should both eventually happen.
194
+ private static func withLifecycleLock<T>(rejectIfBusy: Bool, _ body: () async throws -> T) async throws -> T {
195
+ if rejectIfBusy {
196
+ guard await lifecycleLock.tryAcquire() else {
197
+ throw Exception(
198
+ name: "OperationInProgress",
199
+ description: "Another wallet operation is already in progress",
200
+ code: "ERR_WALLET_OPERATION_IN_PROGRESS"
201
+ )
202
+ }
203
+ } else {
204
+ await lifecycleLock.acquire()
205
+ }
206
+ do {
207
+ let result = try await body()
208
+ await lifecycleLock.release()
209
+ return result
210
+ } catch {
211
+ await lifecycleLock.release()
212
+ throw error
213
+ }
214
+ }
215
+
105
216
  // MARK: - Helpers
106
217
 
107
218
  /// Presents a native UIAlertController showing decoded tx details (chain, recipient, amount,
108
219
  /// fee). The user must tap "Confirm & Sign" before biometric auth fires — this is the only
109
220
  /// place in the native module where informed consent is collected.
110
221
  private static func confirmTransaction(chain: ChainKey, unsignedTx: [String: Any]) async throws {
111
- let message = ChainSigner.buildSummary(chain: chain, unsignedTx: unsignedTx)
222
+ let message = try ChainSigner.buildSummary(chain: chain, unsignedTx: unsignedTx)
112
223
  try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
113
224
  DispatchQueue.main.async {
114
225
  let scene = UIApplication.shared.connectedScenes
@@ -143,7 +254,7 @@ public class ChainberryTrustWalletCoreModule: Module {
143
254
  try NativeWalletStore.saveMnemonic(wallet.mnemonic, walletId: walletId)
144
255
  do {
145
256
  var metadata = try NativeWalletStore.loadMetadata()
146
- metadata[walletId] = addresses
257
+ metadata[walletId] = NativeWalletStore.WalletRecord(isTestnet: isTestnet, addresses: addresses)
147
258
  try NativeWalletStore.saveMetadata(metadata)
148
259
  } catch {
149
260
  // The mnemonic is already persisted but has no metadata pointer — compensate by
@@ -154,7 +265,7 @@ public class ChainberryTrustWalletCoreModule: Module {
154
265
  throw error
155
266
  }
156
267
 
157
- return ["walletId": walletId, "addresses": addresses]
268
+ return ["walletId": walletId, "addresses": addresses, "isTestnet": isTestnet]
158
269
  }
159
270
 
160
271
  /// Prompts biometry-or-device-passcode via `.deviceOwnerAuthentication` (Apple's
@@ -0,0 +1,39 @@
1
+ import XCTest
2
+ import WalletCore
3
+
4
+ // iOS companion to android/src/androidTest/.../AddressDerivationConformanceTest.kt.
5
+ // Verifies that HDWallet.getAddressForCoin produces the expected address for every
6
+ // chain under WalletCore 4.1.19 (the pinned version).
7
+ //
8
+ // The test mnemonic is the BIP39 standard test vector — never use with real funds.
9
+ //
10
+ // How to run:
11
+ // xcodebuild test -workspace ios/vault.xcworkspace -scheme WalletConformanceTests \
12
+ // -destination 'platform=iOS Simulator,name=iPhone 17'
13
+ final class AddressDerivationConformanceTests: XCTestCase {
14
+
15
+ static let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
16
+
17
+ // All addresses confirmed against WalletCore 4.1.19 on-device (Android instrumented test
18
+ // 2026-08-27; iOS confirmed via this test suite). ETH/BNB/POL share CoinType.ethereum.
19
+ static let verified: [(coin: CoinType, chain: String, expected: String)] = [
20
+ (.ethereum, "ethereum", "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"),
21
+ (.smartChain, "bnb", "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"),
22
+ (.ethereum, "polygon", "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"),
23
+ (.bitcoin, "bitcoin", "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu"),
24
+ (.litecoin, "litecoin", "ltc1qjmxnz78nmc8nq77wuxh25n2es7rzm5c2rkk4wh"),
25
+ (.xrp, "xrp", "rHsMGQEkVNJmpGWs8XUBoTBiAAbwxZN5v3"),
26
+ (.tron, "tron", "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH"),
27
+ (.ton, "ton", "UQAzWZa6nM5mJev91wGc7VCSfBoIsYRqKJpV78N8Add9-RKY"),
28
+ (.solana, "solana", "GjJyeC1r2RgkuoCWMyPYkCWSGSGLcz266EaAkLA27AhL"),
29
+ (.bitcoinCash, "bitcoincash", "bitcoincash:qqyx49mu0kkn9ftfj6hje6g2wfer34yfnq5tahq3q6"),
30
+ ]
31
+
32
+ func testVerifiedAddressesMatch() {
33
+ let wallet = HDWallet(mnemonic: Self.mnemonic, passphrase: "")!
34
+ for v in Self.verified {
35
+ let actual = wallet.getAddressForCoin(coin: v.coin)
36
+ XCTAssertEqual(actual, v.expected, "chain '\(v.chain)': derived '\(actual)' ≠ expected '\(v.expected)'")
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,292 @@
1
+ import XCTest
2
+ import WalletCore
3
+
4
+ // Mirrors Android's SigningConformanceTest.kt: reads conformance/signing-vectors.json and asserts
5
+ // iOS WalletCore 4.1.19 produces byte-for-byte identical signed transactions. Covers every status:
6
+ // "verified" — assert output == expectedSignedTx
7
+ // "verified-non-deterministic"— assert signing succeeds and output is structurally valid (TON)
8
+ // "verified-android-only" — run the same input on iOS to confirm cross-platform parity (TRX)
9
+ //
10
+ // Add this file to an XCTest target in vault.xcworkspace that links WalletCore.xcframework.
11
+ class SigningConformanceTests: XCTestCase {
12
+
13
+ private struct SigningVector: Decodable {
14
+ let chain: String
15
+ let status: String
16
+ let unsignedTx: [String: JSONValue]
17
+ let expectedSignedTx: String?
18
+ }
19
+
20
+ private struct FixtureFile: Decodable {
21
+ let testMnemonic: String
22
+ let testPassphrase: String
23
+ let signingVectors: [SigningVector]
24
+ }
25
+
26
+ // Minimal JSON value type so unsignedTx can be decoded without knowing its shape up front.
27
+ private enum JSONValue: Decodable {
28
+ case string(String), int(Int), double(Double), bool(Bool), array([JSONValue]), object([String: JSONValue]), null
29
+ init(from decoder: Decoder) throws {
30
+ let c = try decoder.singleValueContainer()
31
+ if c.decodeNil() { self = .null }
32
+ else if let v = try? c.decode(Bool.self) { self = .bool(v) }
33
+ else if let v = try? c.decode(Int.self) { self = .int(v) }
34
+ else if let v = try? c.decode(Double.self) { self = .double(v) }
35
+ else if let v = try? c.decode(String.self) { self = .string(v) }
36
+ else if let v = try? c.decode([JSONValue].self) { self = .array(v) }
37
+ else { self = .object(try c.decode([String: JSONValue].self)) }
38
+ }
39
+ var string: String? { if case .string(let s) = self { return s }; return nil }
40
+ var int: Int? { if case .int(let i) = self { return i }; return nil }
41
+ var object: [String: JSONValue]? { if case .object(let o) = self { return o }; return nil }
42
+ var array: [JSONValue]? { if case .array(let a) = self { return a }; return nil }
43
+ }
44
+
45
+ private func loadFixture() throws -> FixtureFile {
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
+ }
50
+ return try JSONDecoder().decode(FixtureFile.self, from: Data(contentsOf: url))
51
+ }
52
+
53
+ private var wallet: HDWallet!
54
+
55
+ override func setUpWithError() throws {
56
+ let fixture = try loadFixture()
57
+ guard let w = HDWallet(mnemonic: fixture.testMnemonic, passphrase: fixture.testPassphrase) else {
58
+ throw XCTSkip("HDWallet init failed")
59
+ }
60
+ wallet = w
61
+ }
62
+
63
+ // MARK: - Per-chain tests
64
+
65
+ func testEthereum() throws { try runVector(chain: "ethereum") }
66
+ func testPolygon() throws { try runVector(chain: "polygon") }
67
+ func testBitcoin() throws { try runVector(chain: "bitcoin") }
68
+ func testLitecoin() throws { try runVector(chain: "litecoin") }
69
+ func testXrp() throws { try runVector(chain: "xrp") }
70
+ func testTron() throws { try runVector(chain: "tron") }
71
+ func testSolana() throws { try runVector(chain: "solana") }
72
+ func testTon() throws { try runVector(chain: "ton") }
73
+
74
+ // MARK: - Dispatch
75
+
76
+ private func runVector(chain: String) throws {
77
+ let fixture = try loadFixture()
78
+ guard let v = fixture.signingVectors.first(where: { $0.chain == chain }) else {
79
+ throw XCTSkip("No vector for chain '\(chain)'")
80
+ }
81
+ let tx = v.unsignedTx
82
+ switch chain {
83
+ case "ethereum", "polygon": try assertEvm(v, tx: tx)
84
+ case "bitcoin", "litecoin": try assertUtxo(v, tx: tx)
85
+ case "xrp": try assertXrp(v, tx: tx)
86
+ case "tron": try assertTron(v, tx: tx)
87
+ case "solana": try assertSolana(v, tx: tx)
88
+ case "ton": try assertTon(v, tx: tx)
89
+ default: XCTFail("No signing impl for chain '\(chain)'")
90
+ }
91
+ }
92
+
93
+ // MARK: - EVM
94
+
95
+ private func assertEvm(_ v: SigningVector, tx: [String: JSONValue]) throws {
96
+ let coin: CoinType = .ethereum
97
+ let pk = wallet.getKeyForCoin(coin: coin)
98
+ guard let to = tx["to"]?.string,
99
+ let nonce = tx["nonce"]?.int,
100
+ let gasLim = tx["gasLimitHex"]?.string,
101
+ let chainId = tx["chainId"]?.int else {
102
+ XCTFail("Missing EVM params"); return
103
+ }
104
+ let valueHex = tx["valueHex"]?.string ?? "0"
105
+ var input = EthereumSigningInput()
106
+ input.chainID = BigIntHelper(chainId).toMinimal()
107
+ input.nonce = BigIntHelper(nonce).toMinimal()
108
+ input.gasLimit = Data(hexString: gasLim.padEven())!
109
+ input.toAddress = to
110
+ input.privateKey = pk.data
111
+ var transfer = EthereumTransaction.Transfer()
112
+ transfer.amount = Data(hexString: valueHex.padEven()) ?? Data([0])
113
+ var etx = EthereumTransaction(); etx.transfer = transfer
114
+ input.transaction = etx
115
+ if let gp = tx["gasPriceHex"]?.string {
116
+ input.gasPrice = Data(hexString: gp.padEven())!
117
+ } else if let mf = tx["maxFeePerGasHex"]?.string,
118
+ let pf = tx["maxPriorityFeePerGasHex"]?.string {
119
+ input.txMode = .enveloped
120
+ input.maxFeePerGas = Data(hexString: mf.padEven())!
121
+ input.maxInclusionFeePerGas = Data(hexString: pf.padEven())!
122
+ }
123
+ let out: EthereumSigningOutput = AnySigner.sign(input: input, coin: coin)
124
+ XCTAssertEqual(out.error, .ok, "EVM signing error: \(out.errorMessage)")
125
+ let signed = "0x" + out.encoded.hexString
126
+ if v.status == "verified", let expected = v.expectedSignedTx {
127
+ XCTAssertEqual(signed, expected, "chain '\(v.chain)': signed tx mismatch")
128
+ }
129
+ }
130
+
131
+ // MARK: - UTXO (BTC / LTC)
132
+
133
+ private func assertUtxo(_ v: SigningVector, tx: [String: JSONValue]) throws {
134
+ let coin: CoinType = v.chain == "litecoin" ? .litecoin : .bitcoin
135
+ let pk = wallet.getKeyForCoin(coin: coin)
136
+ guard let toAddress = tx["toAddress"]?.string,
137
+ let changeAddress = tx["changeAddress"]?.string,
138
+ let sendSats = tx["sendAmountSats"]?.string.flatMap(Int64.init),
139
+ let spbNum = tx["satsPerByte"]?.int,
140
+ let inputArr = tx["inputs"]?.array else {
141
+ XCTFail("Missing UTXO params"); return
142
+ }
143
+ var input = BitcoinSigningInput()
144
+ input.hashType = BitcoinScript.hashTypeForCoin(coinType: coin)
145
+ input.amount = sendSats
146
+ input.byteFee = Int64(spbNum)
147
+ input.toAddress = toAddress
148
+ input.changeAddress = changeAddress
149
+ input.useMaxAmount = false
150
+ input.coinType = coin.rawValue
151
+ input.privateKey = [pk.data]
152
+ input.utxo = inputArr.compactMap { entry -> BitcoinUnspentTransaction? in
153
+ guard let obj = entry.object,
154
+ let txId = obj["txIdHex"]?.string,
155
+ let vout = obj["vout"]?.int,
156
+ let amt = obj["amountSats"]?.string.flatMap(Int64.init),
157
+ let script = obj["scriptPubKeyHex"]?.string,
158
+ let scriptData = Data(hexString: script),
159
+ var txIdData = Data(hexString: txId) else { return nil }
160
+ txIdData.reverse()
161
+ var op = BitcoinOutPoint(); op.hash = txIdData; op.index = UInt32(vout)
162
+ var utxo = BitcoinUnspentTransaction()
163
+ utxo.outPoint = op; utxo.amount = amt; utxo.script = scriptData
164
+ return utxo
165
+ }
166
+ let out: BitcoinSigningOutput = AnySigner.sign(input: input, coin: coin)
167
+ XCTAssertEqual(out.error, .ok, "UTXO signing error: \(out.errorMessage)")
168
+ if v.status == "verified", let expected = v.expectedSignedTx {
169
+ XCTAssertEqual(out.encoded.hexString, expected, "chain '\(v.chain)': signed tx mismatch")
170
+ }
171
+ }
172
+
173
+ // MARK: - XRP
174
+
175
+ private func assertXrp(_ v: SigningVector, tx: [String: JSONValue]) throws {
176
+ let pk = wallet.getKeyForCoin(coin: .xrp)
177
+ guard let account = tx["Account"]?.string,
178
+ let dest = tx["Destination"]?.string,
179
+ let amount = tx["Amount"]?.string.flatMap(Int64.init),
180
+ let fee = tx["Fee"]?.string.flatMap(Int64.init),
181
+ let sequence = tx["Sequence"]?.int else {
182
+ XCTFail("Missing XRP params"); return
183
+ }
184
+ var payment = RippleOperationPayment()
185
+ payment.amount = amount; payment.destination = dest
186
+ var input = RippleSigningInput()
187
+ input.privateKey = pk.data; input.account = account
188
+ input.fee = fee; input.sequence = Int32(sequence)
189
+ if let lls = tx["LastLedgerSequence"]?.int { input.lastLedgerSequence = Int32(lls) }
190
+ input.opPayment = payment
191
+ let out: RippleSigningOutput = AnySigner.sign(input: input, coin: .xrp)
192
+ XCTAssertEqual(out.error, .ok, "XRP signing error: \(out.errorMessage)")
193
+ if v.status == "verified", let expected = v.expectedSignedTx {
194
+ XCTAssertEqual(out.encoded.hexString, expected, "chain 'xrp': signed tx mismatch")
195
+ }
196
+ }
197
+
198
+ // MARK: - TRX
199
+
200
+ private func assertTron(_ v: SigningVector, tx: [String: JSONValue]) throws {
201
+ let pk = wallet.getKeyForCoin(coin: .tron)
202
+ guard let txID = tx["txID"]?.string else { XCTFail("Missing TRX txID"); return }
203
+ var input = TronSigningInput()
204
+ input.privateKey = pk.data
205
+ input.txID = txID
206
+ let out: TronSigningOutput = AnySigner.sign(input: input, coin: .tron)
207
+ XCTAssertEqual(out.error, .ok, "TRX signing error: \(out.errorMessage)")
208
+ let signatureHex = out.signature.hexString
209
+ XCTAssertFalse(signatureHex.isEmpty, "TRX: empty signature")
210
+ // verified-android-only: same private key + same digest → same ECDSA sig — mismatch is a real bug.
211
+ if let expected = v.expectedSignedTx,
212
+ let data = expected.data(using: .utf8),
213
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
214
+ let sigs = json["signature"] as? [String],
215
+ let androidSig = sigs.first {
216
+ XCTAssertEqual(signatureHex, androidSig,
217
+ "TRX: iOS signature differs from Android — same digest + key must produce same ECDSA sig")
218
+ }
219
+ }
220
+
221
+ // MARK: - Solana
222
+
223
+ private func assertSolana(_ v: SigningVector, tx: [String: JSONValue]) throws {
224
+ let pk = wallet.getKeyForCoin(coin: .solana)
225
+ guard let b64 = tx["unsignedTxBase64"]?.string,
226
+ let txBytes = Data(base64Encoded: b64) else {
227
+ XCTFail("Missing/invalid Solana unsignedTxBase64"); return
228
+ }
229
+ let decoded = TransactionDecoder.decode(coinType: .solana, encodedTx: txBytes)
230
+ let decodedOut = try SolanaDecodingTransactionOutput(serializedBytes: decoded)
231
+ XCTAssertEqual(decodedOut.error, .ok, "SOL decode error: \(decodedOut.errorMessage)")
232
+ let blockhash = decodedOut.transaction.legacy.recentBlockhash
233
+ let keys = DataVector(); keys.add(data: pk.data)
234
+ let signedBytes = SolanaTransaction.updateBlockhashAndSign(
235
+ encodedTx: b64, recentBlockhash: blockhash, privateKeys: keys)
236
+ let signedOut = try SolanaSigningOutput(serializedBytes: signedBytes)
237
+ XCTAssertEqual(signedOut.error, .ok, "SOL signing error: \(signedOut.errorMessage)")
238
+ if v.status == "verified", let expected = v.expectedSignedTx {
239
+ XCTAssertEqual(signedOut.encoded, expected, "chain 'solana': signed tx mismatch")
240
+ }
241
+ }
242
+
243
+ // MARK: - TON (non-deterministic — verify structure only)
244
+
245
+ private func assertTon(_ v: SigningVector, tx: [String: JSONValue]) throws {
246
+ let pk = wallet.getKeyForCoin(coin: .ton)
247
+ guard let toAddress = tx["toAddress"]?.string,
248
+ let amountStr = tx["amount"]?.string,
249
+ let nanotons = UInt64(amountStr),
250
+ let seqno = tx["seqno"]?.int else {
251
+ XCTFail("Missing TON params"); return
252
+ }
253
+ var transfer = TheOpenNetworkTransfer()
254
+ transfer.dest = toAddress
255
+ transfer.amount = nanotons
256
+ transfer.mode = UInt32(
257
+ TheOpenNetworkSendMode.payFeesSeparately.rawValue |
258
+ TheOpenNetworkSendMode.ignoreActionPhaseErrors.rawValue)
259
+ transfer.bounceable = true
260
+ if let memo = tx["memoId"]?.string { transfer.comment = memo }
261
+ var input = TheOpenNetworkSigningInput()
262
+ input.privateKey = pk.data
263
+ input.walletVersion = .walletV4R2
264
+ input.sequenceNumber = UInt32(seqno)
265
+ input.expireAt = UInt32(Date().timeIntervalSince1970) + 600
266
+ input.messages = [transfer]
267
+ let out: TheOpenNetworkSigningOutput = AnySigner.sign(input: input, coin: .ton)
268
+ XCTAssertEqual(out.error, .ok, "TON signing error: \(out.errorMessage)")
269
+ // Non-deterministic due to wall-clock expireAt — check well-formed BOC and 32-byte hash only.
270
+ XCTAssertTrue(out.encoded.hasPrefix("te6cc"),
271
+ "TON: unexpected BOC prefix in '\(out.encoded)'")
272
+ XCTAssertEqual(out.hash.count, 32,
273
+ "TON: txHash should be 32 bytes, got \(out.hash.count)")
274
+ }
275
+ }
276
+
277
+ // MARK: - Helpers
278
+
279
+ private extension String {
280
+ func padEven() -> String { count % 2 == 0 ? self : "0" + self }
281
+ }
282
+
283
+ private struct BigIntHelper {
284
+ let value: Int
285
+ init(_ v: Int) { value = v }
286
+ func toMinimal() -> Data {
287
+ guard value > 0 else { return Data([0]) }
288
+ var v = value; var bytes: [UInt8] = []
289
+ while v > 0 { bytes.insert(UInt8(v & 0xFF), at: 0); v >>= 8 }
290
+ return Data(bytes)
291
+ }
292
+ }
@@ -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,9 +235,65 @@ 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
231
242
  }
243
+
244
+ // MARK: - Reconciliation (see CONTEXT.md "orphan"/"reconciliation pass", docs/adr/0001)
245
+
246
+ /// Runs once at module init (see `TrustWalletCoreModule`'s `OnCreate`), before the JS layer can
247
+ /// issue its first `createWallet`/`importWallet`/`deleteWallet` call — the actual source of
248
+ /// crash-safety for an interrupted create or delete, not the in-call rollback in
249
+ /// `persistNewWallet`. Finds every Keychain item under `walletServicePrefix` with no matching
250
+ /// metadata entry and deletes it.
251
+ ///
252
+ /// Enumeration requires listing *every* generic-password item (`kSecMatchLimitAll`) — the
253
+ /// Keychain has no service-prefix query — and filtering client-side; this is safe today because
254
+ /// nothing else in this app touches the Keychain directly (see docs/adr/0001). If that ever
255
+ /// changes, this filter must stay airtight or it risks touching an unrelated item.
256
+ ///
257
+ /// Best-effort and never throws: any failure here is logged and skipped, since a broken
258
+ /// reconciliation pass must never become "the app won't launch." A metadata entry with no
259
+ /// matching Keychain item (the reverse shape — a "zombie") is deliberately left untouched here;
260
+ /// see `.notFound` and docs/adr/0001 for why.
261
+ static func reconcileOrphans() {
262
+ let liveIds: Set<String>
263
+ do {
264
+ liveIds = Set(try loadMetadata().keys)
265
+ } catch {
266
+ NSLog("[NativeWalletStore] reconciliation: failed to load metadata, skipping this pass entirely: \(error)")
267
+ return
268
+ }
269
+
270
+ let query: [String: Any] = [
271
+ kSecClass as String: kSecClassGenericPassword,
272
+ kSecMatchLimit as String: kSecMatchLimitAll,
273
+ kSecReturnAttributes as String: true,
274
+ ]
275
+ var result: AnyObject?
276
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
277
+ guard status == errSecSuccess || status == errSecItemNotFound else {
278
+ NSLog("[NativeWalletStore] reconciliation: failed to enumerate Keychain items (OSStatus \(status))")
279
+ return
280
+ }
281
+ let items = (result as? [[String: Any]]) ?? []
282
+
283
+ for item in items {
284
+ guard let service = item[kSecAttrService as String] as? String,
285
+ service.hasPrefix(walletServicePrefix) else { continue }
286
+ let walletId = String(service.dropFirst(walletServicePrefix.count))
287
+ guard !liveIds.contains(walletId) else { continue }
288
+
289
+ let deleteStatus = SecItemDelete([
290
+ kSecClass as String: kSecClassGenericPassword,
291
+ kSecAttrService as String: service,
292
+ kSecAttrAccount as String: mnemonicAccount,
293
+ ] as CFDictionary)
294
+ if deleteStatus != errSecSuccess && deleteStatus != errSecItemNotFound {
295
+ NSLog("[NativeWalletStore] reconciliation: failed to delete orphaned item for \(walletId) (OSStatus \(deleteStatus))")
296
+ }
297
+ }
298
+ }
232
299
  }