@chainberry/trust-wallet-core 2.0.0 → 2.5.1
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} +5 -4
- package/README.md +27 -25
- 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 +884 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/ChainberryTrustWalletCoreModule.kt +227 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/NativeWalletStore.kt +888 -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 +344 -0
- package/expo-module.config.json +3 -2
- package/ios/AmountParsing.swift +62 -0
- package/ios/Bech32.swift +66 -0
- package/ios/ChainSigning.swift +978 -0
- package/ios/ChainberryTrustWalletCoreModule.swift +336 -0
- package/ios/ConformanceTests/AddressDerivationConformanceTests.swift +39 -0
- package/ios/ConformanceTests/SigningConformanceTests.swift +295 -0
- package/ios/NativeWalletStore.swift +288 -0
- package/package.json +4 -3
- package/src/index.ts +42 -13
- 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,336 @@
|
|
|
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
|
+
// 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 }.
|
|
23
|
+
// No BIP-39 passphrase support: signTransaction always reconstructs the wallet with an
|
|
24
|
+
// empty passphrase, so accepting one here would derive addresses from a seed different
|
|
25
|
+
// from the one actually used to sign — always pass "" to stay consistent with that.
|
|
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.
|
|
28
|
+
AsyncFunction("createWallet") { (strength: Int, isTestnet: Bool) throws -> [String: Any] in
|
|
29
|
+
guard let wallet = HDWallet(strength: Int32(strength), passphrase: "") else {
|
|
30
|
+
throw Exception(name: "WalletError", description: "Failed to generate wallet")
|
|
31
|
+
}
|
|
32
|
+
do {
|
|
33
|
+
return try Self.persistNewWallet(wallet: wallet, isTestnet: isTestnet)
|
|
34
|
+
} catch let e as NativeWalletStoreError {
|
|
35
|
+
throw e.asException
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 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`).
|
|
41
|
+
AsyncFunction("importWallet") { (mnemonic: String, isTestnet: Bool) throws -> [String: Any] in
|
|
42
|
+
guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else {
|
|
43
|
+
throw Exception(name: "InvalidMnemonic", description: "Invalid mnemonic phrase")
|
|
44
|
+
}
|
|
45
|
+
do {
|
|
46
|
+
return try Self.persistNewWallet(wallet: wallet, isTestnet: isTestnet)
|
|
47
|
+
} catch let e as NativeWalletStoreError {
|
|
48
|
+
throw e.asException
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Reads only the ungated metadata store — no biometric prompt.
|
|
53
|
+
AsyncFunction("listWallets") { () throws -> [[String: Any]] in
|
|
54
|
+
do {
|
|
55
|
+
return try NativeWalletStore.loadMetadata().map { walletId, addresses in
|
|
56
|
+
["walletId": walletId, "addresses": addresses]
|
|
57
|
+
}
|
|
58
|
+
} catch let e as NativeWalletStoreError {
|
|
59
|
+
throw e.asException
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Irreversible — requires a fresh biometric/passcode confirmation before anything is
|
|
64
|
+
// deleted, same gate as `signTransaction`/`exportMnemonic`. A compromised/malicious JS
|
|
65
|
+
// caller can still invoke this directly (there's no UI call site today), so the gate
|
|
66
|
+
// must live here rather than in JS.
|
|
67
|
+
//
|
|
68
|
+
// Removes the metadata entry *before* the secret (Keychain item) — the reverse of the old
|
|
69
|
+
// ordering. If this is interrupted between the two steps, the wallet is already gone from
|
|
70
|
+
// `listWallets` and only an orphaned Keychain item is left behind, which the next app
|
|
71
|
+
// launch's reconciliation pass cleans up (see docs/adr/0001) — never a metadata record still
|
|
72
|
+
// pointing at a secret that's already gone.
|
|
73
|
+
AsyncFunction("deleteWallet") { (walletId: String) async throws -> Void in
|
|
74
|
+
try await Self.withLifecycleLock(rejectIfBusy: false) {
|
|
75
|
+
do {
|
|
76
|
+
let id = try NativeWalletStore.validateWalletId(walletId)
|
|
77
|
+
_ = try await Self.authenticatedContext(reason: "Delete wallet")
|
|
78
|
+
var metadata = try NativeWalletStore.loadMetadata()
|
|
79
|
+
metadata.removeValue(forKey: id)
|
|
80
|
+
try NativeWalletStore.saveMetadata(metadata)
|
|
81
|
+
try NativeWalletStore.deleteMnemonic(walletId: id)
|
|
82
|
+
} catch let e as NativeWalletStoreError {
|
|
83
|
+
throw e.asException
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 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
|
|
92
|
+
do {
|
|
93
|
+
let id = try NativeWalletStore.validateWalletId(walletId)
|
|
94
|
+
let chainKey = try ChainKey(fromJs: chain)
|
|
95
|
+
try await Self.confirmTransaction(chain: chainKey, unsignedTx: unsignedTx)
|
|
96
|
+
let context = try await Self.authenticatedContext(reason: "Sign transaction")
|
|
97
|
+
let mnemonic = try NativeWalletStore.loadMnemonic(walletId: id, context: context)
|
|
98
|
+
guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else {
|
|
99
|
+
throw Exception(name: "InvalidMnemonic", description: "Stored mnemonic failed validation")
|
|
100
|
+
}
|
|
101
|
+
// Backfill any addresses that were missing when the wallet was first stored
|
|
102
|
+
// (e.g. chains added after the wallet was created). Runs silently after the
|
|
103
|
+
// biometric gate — no extra prompt needed.
|
|
104
|
+
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)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
let result = try ChainSigner.sign(chain: chainKey, wallet: wallet, unsignedTx: unsignedTx, isTestnet: isTestnet)
|
|
119
|
+
var response: [String: Any] = ["signedTx": result.signedTx]
|
|
120
|
+
if let meta = result.meta { response["meta"] = meta }
|
|
121
|
+
return response
|
|
122
|
+
} catch let e as NativeWalletStoreError {
|
|
123
|
+
throw e.asException
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The one sanctioned mnemonic exposure — explicit backup flow only.
|
|
128
|
+
AsyncFunction("exportMnemonic") { (walletId: String) async throws -> String in
|
|
129
|
+
do {
|
|
130
|
+
let id = try NativeWalletStore.validateWalletId(walletId)
|
|
131
|
+
let context = try await Self.authenticatedContext(reason: "Reveal recovery phrase")
|
|
132
|
+
return try NativeWalletStore.loadMnemonic(walletId: id, context: context)
|
|
133
|
+
} catch let e as NativeWalletStoreError {
|
|
134
|
+
throw e.asException
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// MARK: - Lifecycle serialization (see docs/adr/0002)
|
|
140
|
+
|
|
141
|
+
/// Serializes create/import/delete against each other — a `Task`-based actor rather than
|
|
142
|
+
/// `NSLock`, since these calls `await` across the biometric prompt and holding an `NSLock`
|
|
143
|
+
/// across a suspension point (where Swift Concurrency may resume on a different underlying
|
|
144
|
+
/// thread) is unsafe.
|
|
145
|
+
private actor LifecycleLock {
|
|
146
|
+
private var locked = false
|
|
147
|
+
private var waiters: [CheckedContinuation<Void, Never>] = []
|
|
148
|
+
|
|
149
|
+
/// Non-blocking: returns `false` immediately if already held (used by create/import, which
|
|
150
|
+
/// reject rather than queue).
|
|
151
|
+
func tryAcquire() -> Bool {
|
|
152
|
+
guard !locked else { return false }
|
|
153
|
+
locked = true
|
|
154
|
+
return true
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// Blocking: waits until the lock is free, then acquires it (used by delete, which queues).
|
|
158
|
+
func acquire() async {
|
|
159
|
+
guard locked else {
|
|
160
|
+
locked = true
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
await withCheckedContinuation { waiters.append($0) }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// Hands ownership directly to the next waiter rather than freeing the lock and letting
|
|
167
|
+
/// every waiter race a fresh `tryAcquire`/`acquire`.
|
|
168
|
+
func release() {
|
|
169
|
+
if !waiters.isEmpty {
|
|
170
|
+
waiters.removeFirst().resume()
|
|
171
|
+
} else {
|
|
172
|
+
locked = false
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private static let lifecycleLock = LifecycleLock()
|
|
178
|
+
|
|
179
|
+
/// Serializes `body` — including the biometric/passcode prompt, not just the store writes —
|
|
180
|
+
/// against every other lifecycle-mutating call, so at most one is ever touching the shared
|
|
181
|
+
/// metadata store at a time (see docs/adr/0002). Also forecloses a second, separate bug: two
|
|
182
|
+
/// concurrent `LAContext` evaluations racing each other.
|
|
183
|
+
///
|
|
184
|
+
/// `rejectIfBusy` chooses the policy for a caller that finds the lock already held:
|
|
185
|
+
/// `createWallet`/`importWallet` reject immediately (`ERR_WALLET_OPERATION_IN_PROGRESS`) so a
|
|
186
|
+
/// double-tap can never mint two wallets; `deleteWallet` queues instead, since two distinct
|
|
187
|
+
/// deletes are both legitimate and should both eventually happen.
|
|
188
|
+
private static func withLifecycleLock<T>(rejectIfBusy: Bool, _ body: () async throws -> T) async throws -> T {
|
|
189
|
+
if rejectIfBusy {
|
|
190
|
+
guard await lifecycleLock.tryAcquire() else {
|
|
191
|
+
throw Exception(
|
|
192
|
+
name: "OperationInProgress",
|
|
193
|
+
description: "Another wallet operation is already in progress",
|
|
194
|
+
code: "ERR_WALLET_OPERATION_IN_PROGRESS"
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
await lifecycleLock.acquire()
|
|
199
|
+
}
|
|
200
|
+
do {
|
|
201
|
+
let result = try await body()
|
|
202
|
+
await lifecycleLock.release()
|
|
203
|
+
return result
|
|
204
|
+
} catch {
|
|
205
|
+
await lifecycleLock.release()
|
|
206
|
+
throw error
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// MARK: - Helpers
|
|
211
|
+
|
|
212
|
+
/// Presents a native UIAlertController showing decoded tx details (chain, recipient, amount,
|
|
213
|
+
/// fee). The user must tap "Confirm & Sign" before biometric auth fires — this is the only
|
|
214
|
+
/// place in the native module where informed consent is collected.
|
|
215
|
+
private static func confirmTransaction(chain: ChainKey, unsignedTx: [String: Any]) async throws {
|
|
216
|
+
let message = try ChainSigner.buildSummary(chain: chain, unsignedTx: unsignedTx)
|
|
217
|
+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
|
218
|
+
DispatchQueue.main.async {
|
|
219
|
+
let scene = UIApplication.shared.connectedScenes
|
|
220
|
+
.filter({ $0.activationState == .foregroundActive })
|
|
221
|
+
.compactMap({ $0 as? UIWindowScene })
|
|
222
|
+
.first
|
|
223
|
+
var rootVC = scene?.windows.first(where: { $0.isKeyWindow })?.rootViewController
|
|
224
|
+
while let presented = rootVC?.presentedViewController { rootVC = presented }
|
|
225
|
+
guard let topVC = rootVC else {
|
|
226
|
+
continuation.resume(throwing: Exception(name: "NoViewController", description: "Cannot present confirmation"))
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
let alert = UIAlertController(title: "Confirm Transaction", message: message, preferredStyle: .alert)
|
|
230
|
+
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
|
|
231
|
+
continuation.resume(throwing: Exception(name: "UserCancelled", description: "Transaction cancelled by user"))
|
|
232
|
+
})
|
|
233
|
+
alert.addAction(UIAlertAction(title: "Confirm & Sign", style: .default) { _ in
|
|
234
|
+
continuation.resume(returning: ())
|
|
235
|
+
})
|
|
236
|
+
topVC.present(alert, animated: true)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private static func persistNewWallet(wallet: HDWallet, isTestnet: Bool) throws -> [String: Any] {
|
|
242
|
+
let walletId = UUID().uuidString
|
|
243
|
+
var addresses: [String: String] = [:]
|
|
244
|
+
for chain in ChainKey.allCases {
|
|
245
|
+
addresses[chain.rawValue] = ChainSigner.address(for: chain, wallet: wallet, isTestnet: isTestnet)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try NativeWalletStore.saveMnemonic(wallet.mnemonic, walletId: walletId)
|
|
249
|
+
do {
|
|
250
|
+
var metadata = try NativeWalletStore.loadMetadata()
|
|
251
|
+
metadata[walletId] = addresses
|
|
252
|
+
try NativeWalletStore.saveMetadata(metadata)
|
|
253
|
+
} catch {
|
|
254
|
+
// The mnemonic is already persisted but has no metadata pointer — compensate by
|
|
255
|
+
// best-effort deleting it rather than leaving a permanent, invisible orphan. If this
|
|
256
|
+
// rollback delete also fails, there's nothing more useful to do than propagate the
|
|
257
|
+
// original error; the item is at least no worse off than before this call.
|
|
258
|
+
try? NativeWalletStore.deleteMnemonic(walletId: walletId)
|
|
259
|
+
throw error
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return ["walletId": walletId, "addresses": addresses]
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/// Prompts biometry-or-device-passcode via `.deviceOwnerAuthentication` (Apple's
|
|
266
|
+
/// combined policy — no separate fallback branch needed), then hands back the
|
|
267
|
+
/// now-authenticated context for a single Keychain read via `kSecUseAuthenticationContext`.
|
|
268
|
+
private static func authenticatedContext(reason: String) async throws -> LAContext {
|
|
269
|
+
let context = LAContext()
|
|
270
|
+
var evalError: NSError?
|
|
271
|
+
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &evalError) else {
|
|
272
|
+
throw classifyAuthError(evalError, fallbackDescription: "No biometry or device passcode is set up")
|
|
273
|
+
}
|
|
274
|
+
return try await withCheckedThrowingContinuation { continuation in
|
|
275
|
+
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authError in
|
|
276
|
+
if success {
|
|
277
|
+
continuation.resume(returning: context)
|
|
278
|
+
} else {
|
|
279
|
+
continuation.resume(throwing: classifyAuthError(authError, fallbackDescription: "Authentication failed"))
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/// Classifies an `LAError` from either `canEvaluatePolicy`'s precheck or `evaluatePolicy`'s
|
|
286
|
+
/// prompt callback into the same typed error codes Android's `NativeWalletStore.kt`
|
|
287
|
+
/// (`classifyPromptError`/`AuthUnavailable`) uses, so `use-wallet.ts`'s `WALLET_ERROR_COPY` —
|
|
288
|
+
/// written once, keyed by code, shared across both platforms — actually fires here instead of
|
|
289
|
+
/// every prompt failure collapsing into one generic "Authentication failed" banner. This
|
|
290
|
+
/// matters most for cancellation: dismissing the prompt must produce `ERR_WALLET_AUTH_CANCELLED`
|
|
291
|
+
/// (mapped to a quiet no-op, not a banner) on both platforms, not just Android.
|
|
292
|
+
///
|
|
293
|
+
/// Deliberately does not attempt an iOS equivalent of Android's `KeyInvalidated`
|
|
294
|
+
/// (`KeyPermanentlyInvalidatedException` after an enrollment change) — on iOS that surfaces
|
|
295
|
+
/// later, as a `SecItemCopyMatching` `OSStatus` failure inside `loadMnemonic`, not as an
|
|
296
|
+
/// `LAError` here; aligning that would mean auditing `NativeWalletStore.classify(_:)`'s
|
|
297
|
+
/// `errSecAuthFailed`/`errSecInteractionNotAllowed` handling separately; scoped out of this
|
|
298
|
+
/// pass since a wrong OSStatus->meaning mapping there is materially harder to get right without
|
|
299
|
+
/// device verification than this prompt-level LAError classification is.
|
|
300
|
+
private static func classifyAuthError(_ error: Error?, fallbackDescription: String) -> Exception {
|
|
301
|
+
guard let laError = error as? LAError else {
|
|
302
|
+
return Exception(
|
|
303
|
+
name: "AuthenticationFailed",
|
|
304
|
+
description: error?.localizedDescription ?? fallbackDescription,
|
|
305
|
+
code: "ERR_AUTHENTICATION_FAILED"
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
switch laError.code {
|
|
309
|
+
case .userCancel, .appCancel, .systemCancel:
|
|
310
|
+
// User dismissed the prompt rather than authentication actually failing — kept distinct
|
|
311
|
+
// from the default case below so `WALLET_ERROR_COPY`'s `null` entry for this code can
|
|
312
|
+
// treat it as a quiet no-op instead of an error to surface (mirrors Android's
|
|
313
|
+
// `AuthCancelled`).
|
|
314
|
+
return Exception(name: "AuthCancelled", description: "Authentication was cancelled", code: "ERR_WALLET_AUTH_CANCELLED")
|
|
315
|
+
case .biometryLockout:
|
|
316
|
+
// iOS exposes one lockout state (cleared only by a passcode unlock), closest to Android's
|
|
317
|
+
// ERROR_LOCKOUT_PERMANENT rather than its auto-clearing temporary variant.
|
|
318
|
+
return Exception(
|
|
319
|
+
name: "AuthLockedOutPermanent",
|
|
320
|
+
description: "Too many failed authentication attempts — unlock your device to reset",
|
|
321
|
+
code: "ERR_WALLET_AUTH_LOCKED_OUT_PERMANENT"
|
|
322
|
+
)
|
|
323
|
+
case .biometryNotAvailable, .biometryNotEnrolled, .passcodeNotSet:
|
|
324
|
+
// Existing-wallet-use-time unavailability (no secure auth is currently satisfiable) —
|
|
325
|
+
// mirrors Android's `AuthUnavailable` precheck, distinct from `.noDevicePasscode`
|
|
326
|
+
// (`ERR_NO_DEVICE_PASSCODE`) which is specifically the wallet-*creation*-time gate.
|
|
327
|
+
return Exception(
|
|
328
|
+
name: "AuthUnavailable",
|
|
329
|
+
description: "Authentication unavailable: \(laError.localizedDescription)",
|
|
330
|
+
code: "ERR_WALLET_AUTH_UNAVAILABLE"
|
|
331
|
+
)
|
|
332
|
+
default:
|
|
333
|
+
return Exception(name: "AuthenticationFailed", description: laError.localizedDescription, code: "ERR_AUTHENTICATION_FAILED")
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -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,295 @@
|
|
|
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
|
+
let thisFile = URL(fileURLWithPath: #filePath)
|
|
47
|
+
let url = thisFile
|
|
48
|
+
.deletingLastPathComponent()
|
|
49
|
+
.deletingLastPathComponent()
|
|
50
|
+
.deletingLastPathComponent()
|
|
51
|
+
.appendingPathComponent("conformance/signing-vectors.json")
|
|
52
|
+
.standardizedFileURL
|
|
53
|
+
return try JSONDecoder().decode(FixtureFile.self, from: Data(contentsOf: url))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private var wallet: HDWallet!
|
|
57
|
+
|
|
58
|
+
override func setUpWithError() throws {
|
|
59
|
+
let fixture = try loadFixture()
|
|
60
|
+
guard let w = HDWallet(mnemonic: fixture.testMnemonic, passphrase: fixture.testPassphrase) else {
|
|
61
|
+
throw XCTSkip("HDWallet init failed")
|
|
62
|
+
}
|
|
63
|
+
wallet = w
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// MARK: - Per-chain tests
|
|
67
|
+
|
|
68
|
+
func testEthereum() throws { try runVector(chain: "ethereum") }
|
|
69
|
+
func testPolygon() throws { try runVector(chain: "polygon") }
|
|
70
|
+
func testBitcoin() throws { try runVector(chain: "bitcoin") }
|
|
71
|
+
func testLitecoin() throws { try runVector(chain: "litecoin") }
|
|
72
|
+
func testXrp() throws { try runVector(chain: "xrp") }
|
|
73
|
+
func testTron() throws { try runVector(chain: "tron") }
|
|
74
|
+
func testSolana() throws { try runVector(chain: "solana") }
|
|
75
|
+
func testTon() throws { try runVector(chain: "ton") }
|
|
76
|
+
|
|
77
|
+
// MARK: - Dispatch
|
|
78
|
+
|
|
79
|
+
private func runVector(chain: String) throws {
|
|
80
|
+
let fixture = try loadFixture()
|
|
81
|
+
guard let v = fixture.signingVectors.first(where: { $0.chain == chain }) else {
|
|
82
|
+
throw XCTSkip("No vector for chain '\(chain)'")
|
|
83
|
+
}
|
|
84
|
+
let tx = v.unsignedTx
|
|
85
|
+
switch chain {
|
|
86
|
+
case "ethereum", "polygon": try assertEvm(v, tx: tx)
|
|
87
|
+
case "bitcoin", "litecoin": try assertUtxo(v, tx: tx)
|
|
88
|
+
case "xrp": try assertXrp(v, tx: tx)
|
|
89
|
+
case "tron": try assertTron(v, tx: tx)
|
|
90
|
+
case "solana": try assertSolana(v, tx: tx)
|
|
91
|
+
case "ton": try assertTon(v, tx: tx)
|
|
92
|
+
default: XCTFail("No signing impl for chain '\(chain)'")
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// MARK: - EVM
|
|
97
|
+
|
|
98
|
+
private func assertEvm(_ v: SigningVector, tx: [String: JSONValue]) throws {
|
|
99
|
+
let coin: CoinType = .ethereum
|
|
100
|
+
let pk = wallet.getKeyForCoin(coin: coin)
|
|
101
|
+
guard let to = tx["to"]?.string,
|
|
102
|
+
let nonce = tx["nonce"]?.int,
|
|
103
|
+
let gasLim = tx["gasLimitHex"]?.string,
|
|
104
|
+
let chainId = tx["chainId"]?.int else {
|
|
105
|
+
XCTFail("Missing EVM params"); return
|
|
106
|
+
}
|
|
107
|
+
let valueHex = tx["valueHex"]?.string ?? "0"
|
|
108
|
+
var input = EthereumSigningInput()
|
|
109
|
+
input.chainID = BigIntHelper(chainId).toMinimal()
|
|
110
|
+
input.nonce = BigIntHelper(nonce).toMinimal()
|
|
111
|
+
input.gasLimit = Data(hexString: gasLim.padEven())!
|
|
112
|
+
input.toAddress = to
|
|
113
|
+
input.privateKey = pk.data
|
|
114
|
+
var transfer = EthereumTransaction.Transfer()
|
|
115
|
+
transfer.amount = Data(hexString: valueHex.padEven()) ?? Data([0])
|
|
116
|
+
var etx = EthereumTransaction(); etx.transfer = transfer
|
|
117
|
+
input.transaction = etx
|
|
118
|
+
if let gp = tx["gasPriceHex"]?.string {
|
|
119
|
+
input.gasPrice = Data(hexString: gp.padEven())!
|
|
120
|
+
} else if let mf = tx["maxFeePerGasHex"]?.string,
|
|
121
|
+
let pf = tx["maxPriorityFeePerGasHex"]?.string {
|
|
122
|
+
input.txMode = .enveloped
|
|
123
|
+
input.maxFeePerGas = Data(hexString: mf.padEven())!
|
|
124
|
+
input.maxInclusionFeePerGas = Data(hexString: pf.padEven())!
|
|
125
|
+
}
|
|
126
|
+
let out: EthereumSigningOutput = AnySigner.sign(input: input, coin: coin)
|
|
127
|
+
XCTAssertEqual(out.error, .ok, "EVM signing error: \(out.errorMessage)")
|
|
128
|
+
let signed = "0x" + out.encoded.hexString
|
|
129
|
+
if v.status == "verified", let expected = v.expectedSignedTx {
|
|
130
|
+
XCTAssertEqual(signed, expected, "chain '\(v.chain)': signed tx mismatch")
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// MARK: - UTXO (BTC / LTC)
|
|
135
|
+
|
|
136
|
+
private func assertUtxo(_ v: SigningVector, tx: [String: JSONValue]) throws {
|
|
137
|
+
let coin: CoinType = v.chain == "litecoin" ? .litecoin : .bitcoin
|
|
138
|
+
let pk = wallet.getKeyForCoin(coin: coin)
|
|
139
|
+
guard let toAddress = tx["toAddress"]?.string,
|
|
140
|
+
let changeAddress = tx["changeAddress"]?.string,
|
|
141
|
+
let sendSats = tx["sendAmountSats"]?.string.flatMap(Int64.init),
|
|
142
|
+
let spbNum = tx["satsPerByte"]?.int,
|
|
143
|
+
let inputArr = tx["inputs"]?.array else {
|
|
144
|
+
XCTFail("Missing UTXO params"); return
|
|
145
|
+
}
|
|
146
|
+
var input = BitcoinSigningInput()
|
|
147
|
+
input.hashType = BitcoinScript.hashTypeForCoin(coinType: coin)
|
|
148
|
+
input.amount = sendSats
|
|
149
|
+
input.byteFee = Int64(spbNum)
|
|
150
|
+
input.toAddress = toAddress
|
|
151
|
+
input.changeAddress = changeAddress
|
|
152
|
+
input.useMaxAmount = false
|
|
153
|
+
input.coinType = coin.rawValue
|
|
154
|
+
input.privateKey = [pk.data]
|
|
155
|
+
input.utxo = inputArr.compactMap { entry -> BitcoinUnspentTransaction? in
|
|
156
|
+
guard let obj = entry.object,
|
|
157
|
+
let txId = obj["txIdHex"]?.string,
|
|
158
|
+
let vout = obj["vout"]?.int,
|
|
159
|
+
let amt = obj["amountSats"]?.string.flatMap(Int64.init),
|
|
160
|
+
let script = obj["scriptPubKeyHex"]?.string,
|
|
161
|
+
let scriptData = Data(hexString: script),
|
|
162
|
+
var txIdData = Data(hexString: txId) else { return nil }
|
|
163
|
+
txIdData.reverse()
|
|
164
|
+
var op = BitcoinOutPoint(); op.hash = txIdData; op.index = UInt32(vout)
|
|
165
|
+
var utxo = BitcoinUnspentTransaction()
|
|
166
|
+
utxo.outPoint = op; utxo.amount = amt; utxo.script = scriptData
|
|
167
|
+
return utxo
|
|
168
|
+
}
|
|
169
|
+
let out: BitcoinSigningOutput = AnySigner.sign(input: input, coin: coin)
|
|
170
|
+
XCTAssertEqual(out.error, .ok, "UTXO signing error: \(out.errorMessage)")
|
|
171
|
+
if v.status == "verified", let expected = v.expectedSignedTx {
|
|
172
|
+
XCTAssertEqual(out.encoded.hexString, expected, "chain '\(v.chain)': signed tx mismatch")
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// MARK: - XRP
|
|
177
|
+
|
|
178
|
+
private func assertXrp(_ v: SigningVector, tx: [String: JSONValue]) throws {
|
|
179
|
+
let pk = wallet.getKeyForCoin(coin: .xrp)
|
|
180
|
+
guard let account = tx["Account"]?.string,
|
|
181
|
+
let dest = tx["Destination"]?.string,
|
|
182
|
+
let amount = tx["Amount"]?.string.flatMap(Int64.init),
|
|
183
|
+
let fee = tx["Fee"]?.string.flatMap(Int64.init),
|
|
184
|
+
let sequence = tx["Sequence"]?.int else {
|
|
185
|
+
XCTFail("Missing XRP params"); return
|
|
186
|
+
}
|
|
187
|
+
var payment = RippleOperationPayment()
|
|
188
|
+
payment.amount = amount; payment.destination = dest
|
|
189
|
+
var input = RippleSigningInput()
|
|
190
|
+
input.privateKey = pk.data; input.account = account
|
|
191
|
+
input.fee = fee; input.sequence = Int32(sequence)
|
|
192
|
+
if let lls = tx["LastLedgerSequence"]?.int { input.lastLedgerSequence = Int32(lls) }
|
|
193
|
+
input.opPayment = payment
|
|
194
|
+
let out: RippleSigningOutput = AnySigner.sign(input: input, coin: .xrp)
|
|
195
|
+
XCTAssertEqual(out.error, .ok, "XRP signing error: \(out.errorMessage)")
|
|
196
|
+
if v.status == "verified", let expected = v.expectedSignedTx {
|
|
197
|
+
XCTAssertEqual(out.encoded.hexString, expected, "chain 'xrp': signed tx mismatch")
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// MARK: - TRX
|
|
202
|
+
|
|
203
|
+
private func assertTron(_ v: SigningVector, tx: [String: JSONValue]) throws {
|
|
204
|
+
let pk = wallet.getKeyForCoin(coin: .tron)
|
|
205
|
+
guard let txID = tx["txID"]?.string else { XCTFail("Missing TRX txID"); return }
|
|
206
|
+
var input = TronSigningInput()
|
|
207
|
+
input.privateKey = pk.data
|
|
208
|
+
input.txID = txID
|
|
209
|
+
let out: TronSigningOutput = AnySigner.sign(input: input, coin: .tron)
|
|
210
|
+
XCTAssertEqual(out.error, .ok, "TRX signing error: \(out.errorMessage)")
|
|
211
|
+
let signatureHex = out.signature.hexString
|
|
212
|
+
XCTAssertFalse(signatureHex.isEmpty, "TRX: empty signature")
|
|
213
|
+
// verified-android-only: same private key + same digest → same ECDSA sig — mismatch is a real bug.
|
|
214
|
+
if let expected = v.expectedSignedTx,
|
|
215
|
+
let data = expected.data(using: .utf8),
|
|
216
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
217
|
+
let sigs = json["signature"] as? [String],
|
|
218
|
+
let androidSig = sigs.first {
|
|
219
|
+
XCTAssertEqual(signatureHex, androidSig,
|
|
220
|
+
"TRX: iOS signature differs from Android — same digest + key must produce same ECDSA sig")
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// MARK: - Solana
|
|
225
|
+
|
|
226
|
+
private func assertSolana(_ v: SigningVector, tx: [String: JSONValue]) throws {
|
|
227
|
+
let pk = wallet.getKeyForCoin(coin: .solana)
|
|
228
|
+
guard let b64 = tx["unsignedTxBase64"]?.string,
|
|
229
|
+
let txBytes = Data(base64Encoded: b64) else {
|
|
230
|
+
XCTFail("Missing/invalid Solana unsignedTxBase64"); return
|
|
231
|
+
}
|
|
232
|
+
let decoded = TransactionDecoder.decode(coinType: .solana, encodedTx: txBytes)
|
|
233
|
+
let decodedOut = try SolanaDecodingTransactionOutput(serializedBytes: decoded)
|
|
234
|
+
XCTAssertEqual(decodedOut.error, .ok, "SOL decode error: \(decodedOut.errorMessage)")
|
|
235
|
+
let blockhash = decodedOut.transaction.legacy.recentBlockhash
|
|
236
|
+
let keys = DataVector(); keys.add(data: pk.data)
|
|
237
|
+
let signedBytes = SolanaTransaction.updateBlockhashAndSign(
|
|
238
|
+
encodedTx: b64, recentBlockhash: blockhash, privateKeys: keys)
|
|
239
|
+
let signedOut = try SolanaSigningOutput(serializedBytes: signedBytes)
|
|
240
|
+
XCTAssertEqual(signedOut.error, .ok, "SOL signing error: \(signedOut.errorMessage)")
|
|
241
|
+
if v.status == "verified", let expected = v.expectedSignedTx {
|
|
242
|
+
XCTAssertEqual(signedOut.encoded, expected, "chain 'solana': signed tx mismatch")
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// MARK: - TON (non-deterministic — verify structure only)
|
|
247
|
+
|
|
248
|
+
private func assertTon(_ v: SigningVector, tx: [String: JSONValue]) throws {
|
|
249
|
+
let pk = wallet.getKeyForCoin(coin: .ton)
|
|
250
|
+
guard let toAddress = tx["toAddress"]?.string,
|
|
251
|
+
let amountStr = tx["amount"]?.string,
|
|
252
|
+
let nanotons = UInt64(amountStr),
|
|
253
|
+
let seqno = tx["seqno"]?.int else {
|
|
254
|
+
XCTFail("Missing TON params"); return
|
|
255
|
+
}
|
|
256
|
+
var transfer = TheOpenNetworkTransfer()
|
|
257
|
+
transfer.dest = toAddress
|
|
258
|
+
transfer.amount = nanotons
|
|
259
|
+
transfer.mode = UInt32(
|
|
260
|
+
TheOpenNetworkSendMode.payFeesSeparately.rawValue |
|
|
261
|
+
TheOpenNetworkSendMode.ignoreActionPhaseErrors.rawValue)
|
|
262
|
+
transfer.bounceable = true
|
|
263
|
+
if let memo = tx["memoId"]?.string { transfer.comment = memo }
|
|
264
|
+
var input = TheOpenNetworkSigningInput()
|
|
265
|
+
input.privateKey = pk.data
|
|
266
|
+
input.walletVersion = .walletV4R2
|
|
267
|
+
input.sequenceNumber = UInt32(seqno)
|
|
268
|
+
input.expireAt = UInt32(Date().timeIntervalSince1970) + 600
|
|
269
|
+
input.messages = [transfer]
|
|
270
|
+
let out: TheOpenNetworkSigningOutput = AnySigner.sign(input: input, coin: .ton)
|
|
271
|
+
XCTAssertEqual(out.error, .ok, "TON signing error: \(out.errorMessage)")
|
|
272
|
+
// Non-deterministic due to wall-clock expireAt — check well-formed BOC and 32-byte hash only.
|
|
273
|
+
XCTAssertTrue(out.encoded.hasPrefix("te6cc"),
|
|
274
|
+
"TON: unexpected BOC prefix in '\(out.encoded)'")
|
|
275
|
+
XCTAssertEqual(out.hash.count, 32,
|
|
276
|
+
"TON: txHash should be 32 bytes, got \(out.hash.count)")
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// MARK: - Helpers
|
|
281
|
+
|
|
282
|
+
private extension String {
|
|
283
|
+
func padEven() -> String { count % 2 == 0 ? self : "0" + self }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private struct BigIntHelper {
|
|
287
|
+
let value: Int
|
|
288
|
+
init(_ v: Int) { value = v }
|
|
289
|
+
func toMinimal() -> Data {
|
|
290
|
+
guard value > 0 else { return Data([0]) }
|
|
291
|
+
var v = value; var bytes: [UInt8] = []
|
|
292
|
+
while v > 0 { bytes.insert(UInt8(v & 0xFF), at: 0); v >>= 8 }
|
|
293
|
+
return Data(bytes)
|
|
294
|
+
}
|
|
295
|
+
}
|