@chainberry/trust-wallet-core 2.0.0 → 2.5.0
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.
- package/{TrustWalletCoreModule.podspec → ChainberryTrustWalletCoreModule.podspec} +2 -2
- package/README.md +15 -22
- package/android/build.gradle +29 -6
- package/android/libs/README.md +34 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar +0 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar.md5 +1 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar.sha1 +1 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom +22 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom.md5 +1 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom.sha1 +1 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar +0 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar.md5 +1 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar.sha1 +1 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom +21 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom.md5 +1 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom.sha1 +1 -0
- package/android/libs/download.sh +52 -0
- package/android/src/androidTest/java/com/chainberry/trustwalletcore/AddressDerivationConformanceTest.kt +106 -0
- package/android/src/androidTest/java/com/chainberry/trustwalletcore/SigningConformanceTest.kt +186 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/AmountParsing.kt +45 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/Bech32.kt +68 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/ChainSigning.kt +526 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/ChainberryTrustWalletCoreModule.kt +147 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/NativeWalletStore.kt +796 -0
- package/android/src/test/java/com/chainberry/trustwalletcore/AmountParsingConformanceTest.kt +57 -0
- package/android/src/test/java/com/chainberry/trustwalletcore/Bech32Test.kt +35 -0
- package/android/src/test/java/com/chainberry/trustwalletcore/NativeWalletStoreTest.kt +274 -0
- package/expo-module.config.json +3 -2
- package/ios/AmountParsing.swift +62 -0
- package/ios/Bech32.swift +66 -0
- package/ios/ChainSigning.swift +602 -0
- package/ios/ChainberryTrustWalletCoreModule.swift +231 -0
- package/ios/NativeWalletStore.swift +232 -0
- package/package.json +4 -3
- package/src/index.ts +27 -12
- package/android/src/main/java/expo/modules/trustwalletcore/ChainSigning.kt +0 -299
- package/android/src/main/java/expo/modules/trustwalletcore/NativeWalletStore.kt +0 -182
- package/android/src/main/java/expo/modules/trustwalletcore/TrustWalletCoreModule.kt +0 -91
- package/ios/TrustWalletCoreModule.swift +0 -107
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import UIKit
|
|
3
|
+
import WalletCore
|
|
4
|
+
@preconcurrency import LocalAuthentication
|
|
5
|
+
|
|
6
|
+
// Mnemonic/private-key material never crosses back to JS except `exportMnemonic` — an
|
|
7
|
+
// explicit, biometric/passcode-gated backup flow. Every other method returns only
|
|
8
|
+
// walletIds, addresses, or signed transaction bytes/hex.
|
|
9
|
+
public class ChainberryTrustWalletCoreModule: Module {
|
|
10
|
+
public func definition() -> ModuleDefinition {
|
|
11
|
+
Name("TrustWalletCore")
|
|
12
|
+
|
|
13
|
+
// strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses }.
|
|
14
|
+
// No BIP-39 passphrase support: signTransaction always reconstructs the wallet with an
|
|
15
|
+
// empty passphrase, so accepting one here would derive addresses from a seed different
|
|
16
|
+
// from the one actually used to sign — always pass "" to stay consistent with that.
|
|
17
|
+
// 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.
|
|
19
|
+
AsyncFunction("createWallet") { (strength: Int, isTestnet: Bool) throws -> [String: Any] in
|
|
20
|
+
guard let wallet = HDWallet(strength: Int32(strength), passphrase: "") else {
|
|
21
|
+
throw Exception(name: "WalletError", description: "Failed to generate wallet")
|
|
22
|
+
}
|
|
23
|
+
do {
|
|
24
|
+
return try Self.persistNewWallet(wallet: wallet, isTestnet: isTestnet)
|
|
25
|
+
} catch let e as NativeWalletStoreError {
|
|
26
|
+
throw e.asException
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 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`).
|
|
32
|
+
AsyncFunction("importWallet") { (mnemonic: String, isTestnet: Bool) throws -> [String: Any] in
|
|
33
|
+
guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else {
|
|
34
|
+
throw Exception(name: "InvalidMnemonic", description: "Invalid mnemonic phrase")
|
|
35
|
+
}
|
|
36
|
+
do {
|
|
37
|
+
return try Self.persistNewWallet(wallet: wallet, isTestnet: isTestnet)
|
|
38
|
+
} catch let e as NativeWalletStoreError {
|
|
39
|
+
throw e.asException
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Reads only the ungated metadata store — no biometric prompt.
|
|
44
|
+
AsyncFunction("listWallets") { () throws -> [[String: Any]] in
|
|
45
|
+
do {
|
|
46
|
+
return try NativeWalletStore.loadMetadata().map { walletId, addresses in
|
|
47
|
+
["walletId": walletId, "addresses": addresses]
|
|
48
|
+
}
|
|
49
|
+
} catch let e as NativeWalletStoreError {
|
|
50
|
+
throw e.asException
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Irreversible — requires a fresh biometric/passcode confirmation before anything is
|
|
55
|
+
// deleted, same gate as `signTransaction`/`exportMnemonic`. A compromised/malicious JS
|
|
56
|
+
// caller can still invoke this directly (there's no UI call site today), so the gate
|
|
57
|
+
// must live here rather than in JS.
|
|
58
|
+
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
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 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
|
|
75
|
+
do {
|
|
76
|
+
let id = try NativeWalletStore.validateWalletId(walletId)
|
|
77
|
+
let chainKey = try ChainKey(fromJs: chain)
|
|
78
|
+
try await Self.confirmTransaction(chain: chainKey, unsignedTx: unsignedTx)
|
|
79
|
+
let context = try await Self.authenticatedContext(reason: "Sign transaction")
|
|
80
|
+
let mnemonic = try NativeWalletStore.loadMnemonic(walletId: id, context: context)
|
|
81
|
+
guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else {
|
|
82
|
+
throw Exception(name: "InvalidMnemonic", description: "Stored mnemonic failed validation")
|
|
83
|
+
}
|
|
84
|
+
let result = try ChainSigner.sign(chain: chainKey, wallet: wallet, unsignedTx: unsignedTx, isTestnet: isTestnet)
|
|
85
|
+
var response: [String: Any] = ["signedTx": result.signedTx]
|
|
86
|
+
if let meta = result.meta { response["meta"] = meta }
|
|
87
|
+
return response
|
|
88
|
+
} catch let e as NativeWalletStoreError {
|
|
89
|
+
throw e.asException
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The one sanctioned mnemonic exposure — explicit backup flow only.
|
|
94
|
+
AsyncFunction("exportMnemonic") { (walletId: String) async throws -> String in
|
|
95
|
+
do {
|
|
96
|
+
let id = try NativeWalletStore.validateWalletId(walletId)
|
|
97
|
+
let context = try await Self.authenticatedContext(reason: "Reveal recovery phrase")
|
|
98
|
+
return try NativeWalletStore.loadMnemonic(walletId: id, context: context)
|
|
99
|
+
} catch let e as NativeWalletStoreError {
|
|
100
|
+
throw e.asException
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// MARK: - Helpers
|
|
106
|
+
|
|
107
|
+
/// Presents a native UIAlertController showing decoded tx details (chain, recipient, amount,
|
|
108
|
+
/// fee). The user must tap "Confirm & Sign" before biometric auth fires — this is the only
|
|
109
|
+
/// place in the native module where informed consent is collected.
|
|
110
|
+
private static func confirmTransaction(chain: ChainKey, unsignedTx: [String: Any]) async throws {
|
|
111
|
+
let message = ChainSigner.buildSummary(chain: chain, unsignedTx: unsignedTx)
|
|
112
|
+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
|
113
|
+
DispatchQueue.main.async {
|
|
114
|
+
let scene = UIApplication.shared.connectedScenes
|
|
115
|
+
.filter({ $0.activationState == .foregroundActive })
|
|
116
|
+
.compactMap({ $0 as? UIWindowScene })
|
|
117
|
+
.first
|
|
118
|
+
var rootVC = scene?.windows.first(where: { $0.isKeyWindow })?.rootViewController
|
|
119
|
+
while let presented = rootVC?.presentedViewController { rootVC = presented }
|
|
120
|
+
guard let topVC = rootVC else {
|
|
121
|
+
continuation.resume(throwing: Exception(name: "NoViewController", description: "Cannot present confirmation"))
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
let alert = UIAlertController(title: "Confirm Transaction", message: message, preferredStyle: .alert)
|
|
125
|
+
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
|
|
126
|
+
continuation.resume(throwing: Exception(name: "UserCancelled", description: "Transaction cancelled by user"))
|
|
127
|
+
})
|
|
128
|
+
alert.addAction(UIAlertAction(title: "Confirm & Sign", style: .default) { _ in
|
|
129
|
+
continuation.resume(returning: ())
|
|
130
|
+
})
|
|
131
|
+
topVC.present(alert, animated: true)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private static func persistNewWallet(wallet: HDWallet, isTestnet: Bool) throws -> [String: Any] {
|
|
137
|
+
let walletId = UUID().uuidString
|
|
138
|
+
var addresses: [String: String] = [:]
|
|
139
|
+
for chain in ChainKey.allCases {
|
|
140
|
+
addresses[chain.rawValue] = ChainSigner.address(for: chain, wallet: wallet, isTestnet: isTestnet)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try NativeWalletStore.saveMnemonic(wallet.mnemonic, walletId: walletId)
|
|
144
|
+
do {
|
|
145
|
+
var metadata = try NativeWalletStore.loadMetadata()
|
|
146
|
+
metadata[walletId] = addresses
|
|
147
|
+
try NativeWalletStore.saveMetadata(metadata)
|
|
148
|
+
} catch {
|
|
149
|
+
// The mnemonic is already persisted but has no metadata pointer — compensate by
|
|
150
|
+
// best-effort deleting it rather than leaving a permanent, invisible orphan. If this
|
|
151
|
+
// rollback delete also fails, there's nothing more useful to do than propagate the
|
|
152
|
+
// original error; the item is at least no worse off than before this call.
|
|
153
|
+
try? NativeWalletStore.deleteMnemonic(walletId: walletId)
|
|
154
|
+
throw error
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return ["walletId": walletId, "addresses": addresses]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/// Prompts biometry-or-device-passcode via `.deviceOwnerAuthentication` (Apple's
|
|
161
|
+
/// combined policy — no separate fallback branch needed), then hands back the
|
|
162
|
+
/// now-authenticated context for a single Keychain read via `kSecUseAuthenticationContext`.
|
|
163
|
+
private static func authenticatedContext(reason: String) async throws -> LAContext {
|
|
164
|
+
let context = LAContext()
|
|
165
|
+
var evalError: NSError?
|
|
166
|
+
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &evalError) else {
|
|
167
|
+
throw classifyAuthError(evalError, fallbackDescription: "No biometry or device passcode is set up")
|
|
168
|
+
}
|
|
169
|
+
return try await withCheckedThrowingContinuation { continuation in
|
|
170
|
+
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authError in
|
|
171
|
+
if success {
|
|
172
|
+
continuation.resume(returning: context)
|
|
173
|
+
} else {
|
|
174
|
+
continuation.resume(throwing: classifyAuthError(authError, fallbackDescription: "Authentication failed"))
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/// Classifies an `LAError` from either `canEvaluatePolicy`'s precheck or `evaluatePolicy`'s
|
|
181
|
+
/// prompt callback into the same typed error codes Android's `NativeWalletStore.kt`
|
|
182
|
+
/// (`classifyPromptError`/`AuthUnavailable`) uses, so `use-wallet.ts`'s `WALLET_ERROR_COPY` —
|
|
183
|
+
/// written once, keyed by code, shared across both platforms — actually fires here instead of
|
|
184
|
+
/// every prompt failure collapsing into one generic "Authentication failed" banner. This
|
|
185
|
+
/// matters most for cancellation: dismissing the prompt must produce `ERR_WALLET_AUTH_CANCELLED`
|
|
186
|
+
/// (mapped to a quiet no-op, not a banner) on both platforms, not just Android.
|
|
187
|
+
///
|
|
188
|
+
/// Deliberately does not attempt an iOS equivalent of Android's `KeyInvalidated`
|
|
189
|
+
/// (`KeyPermanentlyInvalidatedException` after an enrollment change) — on iOS that surfaces
|
|
190
|
+
/// later, as a `SecItemCopyMatching` `OSStatus` failure inside `loadMnemonic`, not as an
|
|
191
|
+
/// `LAError` here; aligning that would mean auditing `NativeWalletStore.classify(_:)`'s
|
|
192
|
+
/// `errSecAuthFailed`/`errSecInteractionNotAllowed` handling separately; scoped out of this
|
|
193
|
+
/// pass since a wrong OSStatus->meaning mapping there is materially harder to get right without
|
|
194
|
+
/// device verification than this prompt-level LAError classification is.
|
|
195
|
+
private static func classifyAuthError(_ error: Error?, fallbackDescription: String) -> Exception {
|
|
196
|
+
guard let laError = error as? LAError else {
|
|
197
|
+
return Exception(
|
|
198
|
+
name: "AuthenticationFailed",
|
|
199
|
+
description: error?.localizedDescription ?? fallbackDescription,
|
|
200
|
+
code: "ERR_AUTHENTICATION_FAILED"
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
switch laError.code {
|
|
204
|
+
case .userCancel, .appCancel, .systemCancel:
|
|
205
|
+
// User dismissed the prompt rather than authentication actually failing — kept distinct
|
|
206
|
+
// from the default case below so `WALLET_ERROR_COPY`'s `null` entry for this code can
|
|
207
|
+
// treat it as a quiet no-op instead of an error to surface (mirrors Android's
|
|
208
|
+
// `AuthCancelled`).
|
|
209
|
+
return Exception(name: "AuthCancelled", description: "Authentication was cancelled", code: "ERR_WALLET_AUTH_CANCELLED")
|
|
210
|
+
case .biometryLockout:
|
|
211
|
+
// iOS exposes one lockout state (cleared only by a passcode unlock), closest to Android's
|
|
212
|
+
// ERROR_LOCKOUT_PERMANENT rather than its auto-clearing temporary variant.
|
|
213
|
+
return Exception(
|
|
214
|
+
name: "AuthLockedOutPermanent",
|
|
215
|
+
description: "Too many failed authentication attempts — unlock your device to reset",
|
|
216
|
+
code: "ERR_WALLET_AUTH_LOCKED_OUT_PERMANENT"
|
|
217
|
+
)
|
|
218
|
+
case .biometryNotAvailable, .biometryNotEnrolled, .passcodeNotSet:
|
|
219
|
+
// Existing-wallet-use-time unavailability (no secure auth is currently satisfiable) —
|
|
220
|
+
// mirrors Android's `AuthUnavailable` precheck, distinct from `.noDevicePasscode`
|
|
221
|
+
// (`ERR_NO_DEVICE_PASSCODE`) which is specifically the wallet-*creation*-time gate.
|
|
222
|
+
return Exception(
|
|
223
|
+
name: "AuthUnavailable",
|
|
224
|
+
description: "Authentication unavailable: \(laError.localizedDescription)",
|
|
225
|
+
code: "ERR_WALLET_AUTH_UNAVAILABLE"
|
|
226
|
+
)
|
|
227
|
+
default:
|
|
228
|
+
return Exception(name: "AuthenticationFailed", description: laError.localizedDescription, code: "ERR_AUTHENTICATION_FAILED")
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Security
|
|
3
|
+
import LocalAuthentication
|
|
4
|
+
import ExpoModulesCore
|
|
5
|
+
|
|
6
|
+
/// Errors surfaced to the Expo bridge as `Exception`s (via `.asException`) by the caller.
|
|
7
|
+
///
|
|
8
|
+
/// `.notFound` and `.corrupted` are deliberately distinct: `.notFound` means "there is
|
|
9
|
+
/// legitimately nothing here yet" (e.g. no metadata has ever been written, or a wallet id
|
|
10
|
+
/// has no matching Keychain item) and is safe to treat as an empty/absent result. `.corrupted`
|
|
11
|
+
/// means "something is here but it isn't what we expect" (malformed JSON, non-UTF8 mnemonic
|
|
12
|
+
/// bytes) and must never be silently treated as absent — doing so is exactly how a transient
|
|
13
|
+
/// read failure can cause `createWallet` to stomp a real, unreadable index with a fresh one.
|
|
14
|
+
enum NativeWalletStoreError: Error, LocalizedError, CustomStringConvertible {
|
|
15
|
+
case keychainWrite(OSStatus)
|
|
16
|
+
case keychainRead(OSStatus)
|
|
17
|
+
case notFound(walletId: String)
|
|
18
|
+
case corrupted(String)
|
|
19
|
+
case permissionDenied(OSStatus)
|
|
20
|
+
case invalidWalletId(String)
|
|
21
|
+
case noDevicePasscode
|
|
22
|
+
|
|
23
|
+
var description: String {
|
|
24
|
+
switch self {
|
|
25
|
+
case .keychainWrite(let status): return "Keychain write failed (OSStatus \(status))"
|
|
26
|
+
case .keychainRead(let status): return "Keychain read failed (OSStatus \(status))"
|
|
27
|
+
case .notFound(let walletId): return walletId.isEmpty ? "Wallet not found" : "Wallet not found: \(walletId)"
|
|
28
|
+
case .corrupted(let detail): return "Wallet data is corrupted: \(detail)"
|
|
29
|
+
case .permissionDenied(let status): return "Permission denied (OSStatus \(status))"
|
|
30
|
+
case .invalidWalletId(let walletId): return "Invalid wallet id: \(walletId)"
|
|
31
|
+
case .noDevicePasscode: return "A device passcode must be set before creating or importing a wallet"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// `LocalizedError` conformance matters: expo-modules-core wraps any thrown error that is
|
|
36
|
+
// *not* an `Exception` in `UnexpectedException(error)`, whose `reason` is
|
|
37
|
+
// `error.localizedDescription` — not our `description`. Without this, every
|
|
38
|
+
// `NativeWalletStoreError` thrown across the bridge silently loses its message to JS.
|
|
39
|
+
var errorDescription: String? { description }
|
|
40
|
+
|
|
41
|
+
var code: String {
|
|
42
|
+
switch self {
|
|
43
|
+
case .notFound: return "ERR_WALLET_NOT_FOUND"
|
|
44
|
+
case .corrupted: return "ERR_WALLET_DATA_CORRUPTED"
|
|
45
|
+
case .permissionDenied: return "ERR_WALLET_PERMISSION_DENIED"
|
|
46
|
+
case .keychainWrite, .keychainRead: return "ERR_KEYCHAIN_IO"
|
|
47
|
+
case .invalidWalletId: return "ERR_INVALID_WALLET_ID"
|
|
48
|
+
case .noDevicePasscode: return "ERR_NO_DEVICE_PASSCODE"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Converts to a proper Expo `Exception` so `code`/`description` survive the bridge
|
|
53
|
+
/// (see the `errorDescription` note above) instead of being flattened by `UnexpectedException`.
|
|
54
|
+
var asException: Exception {
|
|
55
|
+
Exception(name: "NativeWalletStoreError", description: description, code: code)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Persists mnemonics in the iOS Keychain, gated by biometry-or-device-passcode
|
|
60
|
+
/// (`kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly` + `SecAccessControl`), plus a
|
|
61
|
+
/// parallel ungated metadata store (walletId -> per-chain addresses) for read-only UI
|
|
62
|
+
/// (address display, balance lookups) that shouldn't need a biometric prompt.
|
|
63
|
+
///
|
|
64
|
+
/// Deliberately does not use `react-native-keychain` or wallet-core's `StoredKey`
|
|
65
|
+
/// keystore-JSON format — the mnemonic bytes are the only secret; wrapping them in an
|
|
66
|
+
/// additional password-protected keystore would just relocate the secret to a password
|
|
67
|
+
/// that itself needs identical OS-level gating, for no additional protection.
|
|
68
|
+
enum NativeWalletStore {
|
|
69
|
+
private static let walletServicePrefix = "com.chainberry.vault.wallet."
|
|
70
|
+
private static let mnemonicAccount = "mnemonic"
|
|
71
|
+
private static let metadataService = "com.chainberry.vault.wallet-metadata"
|
|
72
|
+
private static let metadataAccount = "index"
|
|
73
|
+
|
|
74
|
+
/// Classifies a Keychain `OSStatus` into a typed error. `errSecItemNotFound` is handled
|
|
75
|
+
/// by each call site individually (it means different things for a read-that-defaults-to-
|
|
76
|
+
/// empty vs. a delete-that's-idempotent vs. a genuine not-found), so it's intentionally not
|
|
77
|
+
/// folded in here except as a fallback for callers that just want *a* not-found error.
|
|
78
|
+
private static func classify(_ status: OSStatus, walletId: String? = nil, isWrite: Bool = false) -> NativeWalletStoreError {
|
|
79
|
+
switch status {
|
|
80
|
+
case errSecItemNotFound:
|
|
81
|
+
return .notFound(walletId: walletId ?? "")
|
|
82
|
+
case errSecAuthFailed, errSecInteractionNotAllowed, errSecUserCanceled:
|
|
83
|
+
return .permissionDenied(status)
|
|
84
|
+
default:
|
|
85
|
+
return isWrite ? .keychainWrite(status) : .keychainRead(status)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Wallet ids are always internally generated as UUIDs (`UUID().uuidString`). Any
|
|
90
|
+
/// caller-supplied id is validated against that format before it's used to build a
|
|
91
|
+
/// Keychain service string, rejecting malformed/adversarial input up front.
|
|
92
|
+
static func validateWalletId(_ walletId: String) throws -> String {
|
|
93
|
+
guard UUID(uuidString: walletId) != nil else {
|
|
94
|
+
throw NativeWalletStoreError.invalidWalletId(walletId)
|
|
95
|
+
}
|
|
96
|
+
return walletId
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// MARK: - Mnemonic (biometry/passcode gated)
|
|
100
|
+
|
|
101
|
+
static func saveMnemonic(_ mnemonic: String, walletId: String) throws {
|
|
102
|
+
var accessError: Unmanaged<CFError>?
|
|
103
|
+
guard let access = SecAccessControlCreateWithFlags(
|
|
104
|
+
nil,
|
|
105
|
+
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
|
|
106
|
+
[.biometryCurrentSet, .or, .devicePasscode],
|
|
107
|
+
&accessError
|
|
108
|
+
) else {
|
|
109
|
+
throw NativeWalletStoreError.noDevicePasscode
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let service = walletServicePrefix + walletId
|
|
113
|
+
// Delete any existing item first: SecItemAdd fails on a duplicate primary key, and
|
|
114
|
+
// access control cannot be changed via SecItemUpdate — a fresh add is required.
|
|
115
|
+
// errSecItemNotFound (nothing to delete yet) is expected and fine; anything else means
|
|
116
|
+
// the slot may still be occupied, so surface it rather than attempting (and likely
|
|
117
|
+
// failing) the add anyway.
|
|
118
|
+
let deleteStatus = SecItemDelete([
|
|
119
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
120
|
+
kSecAttrService as String: service,
|
|
121
|
+
kSecAttrAccount as String: mnemonicAccount,
|
|
122
|
+
] as CFDictionary)
|
|
123
|
+
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
|
124
|
+
throw classify(deleteStatus, walletId: walletId, isWrite: true)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let addQuery: [String: Any] = [
|
|
128
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
129
|
+
kSecAttrService as String: service,
|
|
130
|
+
kSecAttrAccount as String: mnemonicAccount,
|
|
131
|
+
kSecValueData as String: Data(mnemonic.utf8),
|
|
132
|
+
kSecAttrAccessControl as String: access,
|
|
133
|
+
]
|
|
134
|
+
let status = SecItemAdd(addQuery as CFDictionary, nil)
|
|
135
|
+
guard status == errSecSuccess else {
|
|
136
|
+
throw NativeWalletStoreError.keychainWrite(status)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// Triggers the biometry/passcode prompt (via the supplied `LAContext`, so the caller
|
|
141
|
+
/// controls the prompt's reason string) and returns the mnemonic on success.
|
|
142
|
+
static func loadMnemonic(walletId: String, context: LAContext) throws -> String {
|
|
143
|
+
let query: [String: Any] = [
|
|
144
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
145
|
+
kSecAttrService as String: walletServicePrefix + walletId,
|
|
146
|
+
kSecAttrAccount as String: mnemonicAccount,
|
|
147
|
+
kSecReturnData as String: true,
|
|
148
|
+
kSecUseAuthenticationContext as String: context,
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
var result: AnyObject?
|
|
152
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
153
|
+
guard status == errSecSuccess else {
|
|
154
|
+
throw classify(status, walletId: walletId)
|
|
155
|
+
}
|
|
156
|
+
guard let data = result as? Data, let mnemonic = String(data: data, encoding: .utf8) else {
|
|
157
|
+
throw NativeWalletStoreError.corrupted("stored mnemonic bytes are not valid UTF-8")
|
|
158
|
+
}
|
|
159
|
+
return mnemonic
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// Idempotent: deleting a wallet id that has no Keychain item is treated as success (there's
|
|
163
|
+
/// nothing left to delete), matching normal `deleteWallet` semantics. Any other failure
|
|
164
|
+
/// (e.g. an interaction-not-allowed/auth error) is surfaced rather than silently discarded.
|
|
165
|
+
static func deleteMnemonic(walletId: String) throws {
|
|
166
|
+
let status = SecItemDelete([
|
|
167
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
168
|
+
kSecAttrService as String: walletServicePrefix + walletId,
|
|
169
|
+
kSecAttrAccount as String: mnemonicAccount,
|
|
170
|
+
] as CFDictionary)
|
|
171
|
+
guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
172
|
+
throw classify(status, walletId: walletId, isWrite: true)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// MARK: - Metadata (ungated: walletId -> { chain: address })
|
|
177
|
+
|
|
178
|
+
/// Atomically replaces the metadata blob via `SecItemUpdate` when it already exists,
|
|
179
|
+
/// falling back to `SecItemAdd` only on the very first write. Unlike the mnemonic item,
|
|
180
|
+
/// the metadata item carries no `SecAccessControl`, so there's no access-control-change
|
|
181
|
+
/// obstacle to updating in place — this removes the delete-then-add race window entirely
|
|
182
|
+
/// (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)
|
|
185
|
+
let baseQuery: [String: Any] = [
|
|
186
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
187
|
+
kSecAttrService as String: metadataService,
|
|
188
|
+
kSecAttrAccount as String: metadataAccount,
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
let updateStatus = SecItemUpdate(baseQuery as CFDictionary, [kSecValueData as String: data] as CFDictionary)
|
|
192
|
+
if updateStatus == errSecItemNotFound {
|
|
193
|
+
var addQuery = baseQuery
|
|
194
|
+
addQuery[kSecValueData as String] = data
|
|
195
|
+
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
|
196
|
+
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
|
197
|
+
guard addStatus == errSecSuccess else {
|
|
198
|
+
throw NativeWalletStoreError.keychainWrite(addStatus)
|
|
199
|
+
}
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
guard updateStatus == errSecSuccess else {
|
|
203
|
+
throw NativeWalletStoreError.keychainWrite(updateStatus)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/// Distinguishes "no metadata has ever been written" (legitimately empty — `errSecItemNotFound`)
|
|
208
|
+
/// from a genuine read/corruption failure, which now throws instead of being silently
|
|
209
|
+
/// masked as an empty map. Masking it was the root cause of the "transient read failure
|
|
210
|
+
/// 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]] {
|
|
213
|
+
let query: [String: Any] = [
|
|
214
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
215
|
+
kSecAttrService as String: metadataService,
|
|
216
|
+
kSecAttrAccount as String: metadataAccount,
|
|
217
|
+
kSecReturnData as String: true,
|
|
218
|
+
]
|
|
219
|
+
var result: AnyObject?
|
|
220
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
221
|
+
if status == errSecItemNotFound {
|
|
222
|
+
return [:]
|
|
223
|
+
}
|
|
224
|
+
guard status == errSecSuccess, let data = result as? Data else {
|
|
225
|
+
throw classify(status)
|
|
226
|
+
}
|
|
227
|
+
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: [String: String]] else {
|
|
228
|
+
throw NativeWalletStoreError.corrupted("metadata JSON is unreadable")
|
|
229
|
+
}
|
|
230
|
+
return obj
|
|
231
|
+
}
|
|
232
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chainberry/trust-wallet-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
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",
|
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"src",
|
|
13
|
-
"ios
|
|
13
|
+
"ios",
|
|
14
14
|
"android/src",
|
|
15
15
|
"android/build.gradle",
|
|
16
|
+
"android/libs",
|
|
16
17
|
"expo-module.config.json",
|
|
17
|
-
"
|
|
18
|
+
"ChainberryTrustWalletCoreModule.podspec",
|
|
18
19
|
"README.md"
|
|
19
20
|
],
|
|
20
21
|
"peerDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -4,18 +4,24 @@ import { requireNativeModule } from "expo-modules-core";
|
|
|
4
4
|
* Mnemonic/private-key material never crosses this boundary except `exportMnemonic` —
|
|
5
5
|
* an explicit, biometric/passcode-gated backup flow. Every other function returns only
|
|
6
6
|
* walletIds, addresses, or signed transaction bytes/hex; wallet storage and all signing
|
|
7
|
-
* happen entirely inside the native module (see ios/
|
|
8
|
-
* android/.../
|
|
7
|
+
* happen entirely inside the native module (see ios/ChainberryTrustWalletCoreModule.swift,
|
|
8
|
+
* android/.../ChainberryTrustWalletCoreModule.kt).
|
|
9
9
|
*/
|
|
10
10
|
export type Chain =
|
|
11
11
|
| "ethereum"
|
|
12
12
|
| "bnb"
|
|
13
|
-
| "polygon"
|
|
13
|
+
| "polygon" // shares Ethereum's secp256k1 key/address — same BIP44 path, no distinct derivation
|
|
14
|
+
| "avax" // Avalanche C-Chain — EVM, same key derivation as Ethereum
|
|
15
|
+
| "base" // Base — EVM L2, same key derivation as Ethereum
|
|
16
|
+
| "arbitrum" // Arbitrum One — EVM L2, same key derivation as Ethereum
|
|
17
|
+
| "optimism" // Optimism — EVM L2, same key derivation as Ethereum
|
|
18
|
+
| "sonic" // Sonic — EVM, same key derivation as Ethereum
|
|
14
19
|
| "solana"
|
|
15
20
|
| "tron"
|
|
16
21
|
| "ton"
|
|
17
22
|
| "bitcoin"
|
|
18
|
-
| "bitcoincash"
|
|
23
|
+
| "bitcoincash"
|
|
24
|
+
| "dogecoin"
|
|
19
25
|
| "litecoin"
|
|
20
26
|
| "xrp";
|
|
21
27
|
|
|
@@ -32,15 +38,21 @@ export type SignResult = {
|
|
|
32
38
|
const TrustWalletCore = requireNativeModule("TrustWalletCore");
|
|
33
39
|
|
|
34
40
|
/** Generates a new mnemonic and persists it natively (biometry/passcode-gated).
|
|
35
|
-
* strength 128 = 12 words, 256 = 24 words.
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
* strength 128 = 12 words, 256 = 24 words. No BIP-39 passphrase support: signing always
|
|
42
|
+
* reconstructs the wallet from the mnemonic alone, so a caller-supplied passphrase here
|
|
43
|
+
* would derive addresses from a seed different from the one actually used to sign.
|
|
44
|
+
* `isTestnet` selects the address format for BTC/LTC/BCH (every other chain's address is
|
|
45
|
+
* identical on mainnet and testnet) — callers should pass `IS_TESTNET` from
|
|
46
|
+
* `@/constants/wallet-env`. */
|
|
47
|
+
export async function createWallet(strength: 128 | 256 = 128, isTestnet = false): Promise<WalletSummary> {
|
|
48
|
+
return TrustWalletCore.createWallet(strength, isTestnet);
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
/** One-time mnemonic exposure from the caller — persisted natively immediately, never
|
|
41
|
-
* retained in JS after this call returns.
|
|
42
|
-
|
|
43
|
-
|
|
52
|
+
* retained in JS after this call returns. No BIP-39 passphrase support (see `createWallet`).
|
|
53
|
+
* `isTestnet` — see `createWallet`. */
|
|
54
|
+
export async function importWallet(mnemonic: string, isTestnet = false): Promise<WalletSummary> {
|
|
55
|
+
return TrustWalletCore.importWallet(mnemonic, isTestnet);
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
/** Reads only public metadata (walletId + addresses) — no biometric prompt. */
|
|
@@ -53,13 +65,16 @@ export async function deleteWallet(walletId: string): Promise<void> {
|
|
|
53
65
|
}
|
|
54
66
|
|
|
55
67
|
/** Triggers the native biometry/passcode prompt, then signs entirely in-process —
|
|
56
|
-
* only signed transaction bytes/hex cross back.
|
|
68
|
+
* only signed transaction bytes/hex cross back. `isTestnet` must match whatever
|
|
69
|
+
* `createWallet`/`importWallet` used for this wallet (see those for why) — pass
|
|
70
|
+
* `IS_TESTNET` from `@/constants/wallet-env`. */
|
|
57
71
|
export async function signTransaction(
|
|
58
72
|
walletId: string,
|
|
59
73
|
chain: Chain,
|
|
60
74
|
unsignedTx: Record<string, unknown>,
|
|
75
|
+
isTestnet = false,
|
|
61
76
|
): Promise<SignResult> {
|
|
62
|
-
return TrustWalletCore.signTransaction(walletId, chain, unsignedTx);
|
|
77
|
+
return TrustWalletCore.signTransaction(walletId, chain, unsignedTx, isTestnet);
|
|
63
78
|
}
|
|
64
79
|
|
|
65
80
|
/** The one sanctioned mnemonic exposure — explicit backup/reveal flow only, gated behind
|