@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.
Files changed (39) hide show
  1. package/{TrustWalletCoreModule.podspec → ChainberryTrustWalletCoreModule.podspec} +2 -2
  2. package/README.md +15 -22
  3. package/android/build.gradle +29 -6
  4. package/android/libs/README.md +34 -0
  5. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar +0 -0
  6. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar.md5 +1 -0
  7. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar.sha1 +1 -0
  8. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom +22 -0
  9. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom.md5 +1 -0
  10. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom.sha1 +1 -0
  11. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar +0 -0
  12. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar.md5 +1 -0
  13. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar.sha1 +1 -0
  14. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom +21 -0
  15. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom.md5 +1 -0
  16. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom.sha1 +1 -0
  17. package/android/libs/download.sh +52 -0
  18. package/android/src/androidTest/java/com/chainberry/trustwalletcore/AddressDerivationConformanceTest.kt +106 -0
  19. package/android/src/androidTest/java/com/chainberry/trustwalletcore/SigningConformanceTest.kt +186 -0
  20. package/android/src/main/java/com/chainberry/trustwalletcore/AmountParsing.kt +45 -0
  21. package/android/src/main/java/com/chainberry/trustwalletcore/Bech32.kt +68 -0
  22. package/android/src/main/java/com/chainberry/trustwalletcore/ChainSigning.kt +526 -0
  23. package/android/src/main/java/com/chainberry/trustwalletcore/ChainberryTrustWalletCoreModule.kt +147 -0
  24. package/android/src/main/java/com/chainberry/trustwalletcore/NativeWalletStore.kt +796 -0
  25. package/android/src/test/java/com/chainberry/trustwalletcore/AmountParsingConformanceTest.kt +57 -0
  26. package/android/src/test/java/com/chainberry/trustwalletcore/Bech32Test.kt +35 -0
  27. package/android/src/test/java/com/chainberry/trustwalletcore/NativeWalletStoreTest.kt +274 -0
  28. package/expo-module.config.json +3 -2
  29. package/ios/AmountParsing.swift +62 -0
  30. package/ios/Bech32.swift +66 -0
  31. package/ios/ChainSigning.swift +602 -0
  32. package/ios/ChainberryTrustWalletCoreModule.swift +231 -0
  33. package/ios/NativeWalletStore.swift +232 -0
  34. package/package.json +4 -3
  35. package/src/index.ts +27 -12
  36. package/android/src/main/java/expo/modules/trustwalletcore/ChainSigning.kt +0 -299
  37. package/android/src/main/java/expo/modules/trustwalletcore/NativeWalletStore.kt +0 -182
  38. package/android/src/main/java/expo/modules/trustwalletcore/TrustWalletCoreModule.kt +0 -91
  39. package/ios/TrustWalletCoreModule.swift +0 -107
@@ -0,0 +1,57 @@
1
+ package com.chainberry.trustwalletcore
2
+
3
+ import org.json.JSONArray
4
+ import org.junit.Assert.assertTrue
5
+ import org.junit.Assert.fail
6
+ import org.junit.Test
7
+ import java.io.File
8
+
9
+ // Reads ../../../../../../conformance/amount-parsing-cases.json — the single fixture also
10
+ // consumed by iOS's ConformanceTests/Tests/AmountParsingConformanceTests/ — and asserts
11
+ // AmountParsing.kt agrees with AmountParsing.swift on throw-vs-valid for every case. Keeps
12
+ // the two platforms' numeric parsing behavior from drifting apart. Plain JVM unit test (no
13
+ // Robolectric/instrumentation needed — AmountParsing.kt has no Android framework dependency),
14
+ // runs via `./gradlew testDebugUnitTest` (or the autolinked module's equivalent task).
15
+ class AmountParsingConformanceTest {
16
+
17
+ // Gradle's `Test` task runs with the working directory set to the Gradle project directory
18
+ // (android/) by default — conformance/ is one level up, at the trust-wallet-core module root.
19
+ private fun fixtureFile(): File {
20
+ val projectDir = File(System.getProperty("user.dir") ?: ".")
21
+ return File(projectDir, "../conformance/amount-parsing-cases.json").canonicalFile
22
+ }
23
+
24
+ @Test
25
+ fun conformanceCasesMatch() {
26
+ val fixture = fixtureFile()
27
+ assertTrue("expected conformance fixture at ${fixture.path}", fixture.exists())
28
+ val cases = JSONArray(fixture.readText())
29
+ assertTrue("conformance fixture should not be empty", cases.length() > 0)
30
+
31
+ for (i in 0 until cases.length()) {
32
+ val testCase = cases.getJSONObject(i)
33
+ val id = testCase.getString("id")
34
+ val fn = testCase.getString("fn")
35
+ val expect = testCase.getString("expect")
36
+
37
+ var threw = false
38
+ try {
39
+ when (fn) {
40
+ "xrpAmountDrops" -> parseXrpAmountDrops(testCase.getString("input"))
41
+ "xrpFeeDrops" -> parseXrpFeeDrops(testCase.getString("input"))
42
+ "xrpDestinationTag" -> parseXrpDestinationTag(testCase.getLong("input"))
43
+ "tonNanotons" -> parseTonNanotons(testCase.getString("input"))
44
+ else -> fail("Unknown conformance case fn: $fn")
45
+ }
46
+ } catch (e: ChainSigningException) {
47
+ threw = true
48
+ }
49
+
50
+ val expectedThrow = expect == "throws"
51
+ assertTrue(
52
+ "case $id: expected $expect but got ${if (threw) "throws" else "valid"}",
53
+ threw == expectedThrow
54
+ )
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,35 @@
1
+ package com.chainberry.trustwalletcore
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Test
5
+
6
+ // Plain JVM unit test (no wallet-core JNI needed) — verifies Bech32.encodeSegwitV0.
7
+ //
8
+ // p2wpkhVectorMatches is checked against a real, independently-known-valid mainnet address
9
+ // (its checksum was verified by decoding it with a from-scratch bech32 decoder before use
10
+ // here — this isn't just a hand-copied hex string). p2wshVectorMatches and
11
+ // testnetHrpVectorMatches exercise the same (by then proven-correct) encoder on a 32-byte
12
+ // program and a different hrp, generated by that same verified implementation.
13
+ class Bech32Test {
14
+
15
+ private fun hexToBytes(hex: String): ByteArray =
16
+ ByteArray(hex.length / 2) { ((Character.digit(hex[it * 2], 16) shl 4) + Character.digit(hex[it * 2 + 1], 16)).toByte() }
17
+
18
+ @Test
19
+ fun p2wpkhVectorMatches() {
20
+ val program = hexToBytes("751e76e8199196d454941c45d1b3a323f1433bd6")
21
+ assertEquals("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", Bech32.encodeSegwitV0("bc", program))
22
+ }
23
+
24
+ @Test
25
+ fun p2wshVectorMatches() {
26
+ val program = hexToBytes("1863143c14c5166804bd19203356da136c985678cd4d27a1b8c6329604903262")
27
+ assertEquals("bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3", Bech32.encodeSegwitV0("bc", program))
28
+ }
29
+
30
+ @Test
31
+ fun testnetHrpVectorMatches() {
32
+ val program = hexToBytes("1863143c14c5166804bd19203356da136c985678cd4d27a1b8c6329604903262")
33
+ assertEquals("tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7", Bech32.encodeSegwitV0("tb", program))
34
+ }
35
+ }
@@ -0,0 +1,274 @@
1
+ package com.chainberry.trustwalletcore
2
+
3
+ import android.security.keystore.KeyProperties
4
+ import androidx.biometric.BiometricManager
5
+ import androidx.biometric.BiometricPrompt
6
+ import org.junit.Assert.assertEquals
7
+ import org.junit.Assert.assertFalse
8
+ import org.junit.Assert.assertTrue
9
+ import org.junit.Assert.fail
10
+ import org.junit.Rule
11
+ import org.junit.Test
12
+ import org.junit.rules.TemporaryFolder
13
+ import java.io.File
14
+ import java.util.UUID
15
+
16
+ // Plain JVM unit tests (no Robolectric/instrumentation, same rationale as
17
+ // AmountParsingConformanceTest) for the pieces of NativeWalletStore that don't need an
18
+ // Android Context/Keystore/BiometricPrompt: wallet-id validation, the File-based
19
+ // metadata/delete helpers, the keyAlias scheme, the pure canAuthenticate()-result /
20
+ // BiometricPrompt-errorCode classification functions, and the pure KeyInfo-securityLevel
21
+ // description functions — all extracted specifically to be testable this way. The auth-gated
22
+ // paths that actually drive a Keystore key or a FragmentActivity/BiometricPrompt (mode
23
+ // resolution, both authenticateFor*Wallet flows, confirmIdentity, the StrongBox-request/fallback
24
+ // in generateKey, and the real KeyInfo introspection in logKeySecurityLevel) need a real
25
+ // device/emulator and are covered by manual end-to-end verification instead (see the remediation
26
+ // plan's verification section — API 24/28/29/30/35 x biometric-only/credential-only/both/
27
+ // neither/changed-enrollment/lockout states, plus StrongBox-present/TEE-only/software-only key
28
+ // generation).
29
+ class NativeWalletStoreTest {
30
+
31
+ @get:Rule
32
+ val tmp = TemporaryFolder()
33
+
34
+ // ─── validateWalletId ────────────────────────────────────────────────────────
35
+
36
+ @Test
37
+ fun validateWalletId_acceptsFreshUuid() {
38
+ val id = UUID.randomUUID().toString()
39
+ assertEquals(id, NativeWalletStore.validateWalletId(id))
40
+ }
41
+
42
+ @Test
43
+ fun validateWalletId_rejectsPathTraversal() {
44
+ assertThrowsInvalidWalletId("../etc/passwd")
45
+ }
46
+
47
+ @Test
48
+ fun validateWalletId_rejectsEmptyString() {
49
+ assertThrowsInvalidWalletId("")
50
+ }
51
+
52
+ @Test
53
+ fun validateWalletId_rejectsMalformedUuid() {
54
+ assertThrowsInvalidWalletId("not-a-uuid")
55
+ }
56
+
57
+ private fun assertThrowsInvalidWalletId(walletId: String) {
58
+ try {
59
+ NativeWalletStore.validateWalletId(walletId)
60
+ fail("expected InvalidWalletId for '$walletId'")
61
+ } catch (e: NativeWalletStoreError.InvalidWalletId) {
62
+ // expected
63
+ }
64
+ }
65
+
66
+ // ─── metadata round-trip / corruption / atomicity ───────────────────────────
67
+
68
+ @Test
69
+ fun metadata_roundTrips() {
70
+ val file = File(tmp.root, "metadata.json")
71
+ val wallets = mapOf(
72
+ "id-1" to mapOf("ethereum" to "0xabc"),
73
+ "id-2" to mapOf("bitcoin" to "bc1abc"),
74
+ )
75
+ NativeWalletStore.saveMetadataToFile(file, wallets)
76
+ assertEquals(wallets, NativeWalletStore.loadMetadataFromFile(file))
77
+ }
78
+
79
+ @Test
80
+ fun metadata_missingFileIsEmptyMap() {
81
+ val file = File(tmp.root, "does-not-exist.json")
82
+ assertTrue(NativeWalletStore.loadMetadataFromFile(file).isEmpty())
83
+ }
84
+
85
+ @Test
86
+ fun metadata_corruptFileThrowsCorrupted_notSilentlyEmpty() {
87
+ val file = File(tmp.root, "metadata.json")
88
+ file.writeText("{ not valid json")
89
+ try {
90
+ NativeWalletStore.loadMetadataFromFile(file)
91
+ fail("expected Corrupted for malformed JSON")
92
+ } catch (e: NativeWalletStoreError.Corrupted) {
93
+ // expected — this is the key regression check: a corrupt store must never look
94
+ // identical to an empty one (that's what let createWallet stomp a real, unreadable
95
+ // index in the original incident).
96
+ }
97
+ }
98
+
99
+ @Test
100
+ fun metadata_failedWrite_leavesExistingFileUntouched() {
101
+ val target = File(tmp.root, "metadata.json")
102
+ val original = mapOf("id-1" to mapOf("ethereum" to "0xabc"))
103
+ NativeWalletStore.saveMetadataToFile(target, original)
104
+
105
+ // Making the parent directory read-only blocks creating the temp file at all (the
106
+ // earliest possible failure point of the write), which must surface as a typed
107
+ // PermissionDenied — not a raw IOException, and the pre-existing target file must be
108
+ // left exactly as it was (this is the atomicity guarantee: a failed write never touches
109
+ // the previously-committed file).
110
+ val originalBytes = target.readText()
111
+ target.parentFile!!.setWritable(false)
112
+ try {
113
+ try {
114
+ NativeWalletStore.saveMetadataToFile(target, mapOf("id-2" to mapOf("bitcoin" to "bc1xyz")))
115
+ // Some filesystems/CI runners ignore setWritable(false) for the owner (e.g. root).
116
+ // If the write unexpectedly succeeded, there's nothing to assert here.
117
+ } catch (e: NativeWalletStoreError.PermissionDenied) {
118
+ assertEquals(originalBytes, target.readText())
119
+ }
120
+ } finally {
121
+ target.parentFile!!.setWritable(true)
122
+ }
123
+ }
124
+
125
+ // ─── deleteFileChecked ───────────────────────────────────────────────────────
126
+
127
+ @Test
128
+ fun deleteFileChecked_missingFileIsNoOp() {
129
+ val file = File(tmp.root, "missing.enc")
130
+ // Should not throw even though the file was never created.
131
+ NativeWalletStore.deleteFileChecked(file, "some-wallet-id")
132
+ }
133
+
134
+ @Test
135
+ fun deleteFileChecked_deletesExistingFile() {
136
+ val file = File(tmp.root, "present.enc")
137
+ file.writeBytes(byteArrayOf(1, 2, 3))
138
+ NativeWalletStore.deleteFileChecked(file, "some-wallet-id")
139
+ assertFalse(file.exists())
140
+ }
141
+
142
+ @Test
143
+ fun deleteFileChecked_undeletableFileThrowsDeleteFailed() {
144
+ val dir = File(tmp.root, "readonly-dir").apply { mkdirs() }
145
+ val file = File(dir, "present.enc")
146
+ file.writeBytes(byteArrayOf(1, 2, 3))
147
+ dir.setWritable(false)
148
+ try {
149
+ try {
150
+ NativeWalletStore.deleteFileChecked(file, "some-wallet-id")
151
+ // Some filesystems/CI runners (e.g. running as root) ignore setWritable(false);
152
+ // nothing to assert if the delete unexpectedly succeeded.
153
+ } catch (e: NativeWalletStoreError.DeleteFailed) {
154
+ // expected
155
+ }
156
+ } finally {
157
+ dir.setWritable(true)
158
+ }
159
+ }
160
+
161
+ // ─── keyAlias ────────────────────────────────────────────────────────────────
162
+
163
+ @Test
164
+ fun keyAlias_encodesModeAndIsDistinctPerMode() {
165
+ val id = UUID.randomUUID().toString()
166
+ val bioAlias = NativeWalletStore.keyAlias(AuthMode.BIOMETRIC_STRONG, id)
167
+ val credAlias = NativeWalletStore.keyAlias(AuthMode.DEVICE_CREDENTIAL, id)
168
+ assertTrue(bioAlias.contains(id))
169
+ assertTrue(credAlias.contains(id))
170
+ assertFalse(bioAlias == credAlias)
171
+ }
172
+
173
+ @Test
174
+ fun keyAlias_legacyCombinedHasNoInfix() {
175
+ // Regression check for the orphaned-wallet bug: a wallet created before the
176
+ // biometric/device-credential split used a bare "vault_wallet_<id>" alias (no bio_/cred_
177
+ // infix). resolveExistingMode's legacy fallback depends on keyAlias(LEGACY_COMBINED, id)
178
+ // reproducing that exact string, or a pre-split wallet becomes permanently unreachable again.
179
+ val id = UUID.randomUUID().toString()
180
+ val legacyAlias = NativeWalletStore.keyAlias(AuthMode.LEGACY_COMBINED, id)
181
+ assertEquals("vault_wallet_$id", legacyAlias)
182
+ }
183
+
184
+ // ─── describeUnavailableReason ───────────────────────────────────────────────
185
+
186
+ @Test
187
+ fun describeUnavailableReason_mapsEachKnownCode() {
188
+ val codes = listOf(
189
+ BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED,
190
+ BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
191
+ BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE,
192
+ BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED,
193
+ BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED,
194
+ BiometricManager.BIOMETRIC_STATUS_UNKNOWN,
195
+ )
196
+ // Every known code gets a distinct, non-generic reason string (i.e. none of them fall
197
+ // through to the "unavailable (status $n)" catch-all).
198
+ val reasons = codes.map { NativeWalletStore.describeUnavailableReason(it) }
199
+ assertEquals(reasons.size, reasons.toSet().size)
200
+ reasons.forEach { assertFalse(it.startsWith("unavailable (status")) }
201
+ }
202
+
203
+ @Test
204
+ fun describeUnavailableReason_unknownCodeFallsThroughToGenericMessage() {
205
+ val reason = NativeWalletStore.describeUnavailableReason(-999)
206
+ assertTrue(reason.contains("-999"))
207
+ }
208
+
209
+ // ─── classifyPromptError ─────────────────────────────────────────────────────
210
+
211
+ @Test
212
+ fun classifyPromptError_lockoutIsTemporary() {
213
+ val error = NativeWalletStore.classifyPromptError(BiometricPrompt.ERROR_LOCKOUT, "locked out")
214
+ assertTrue(error is NativeWalletStoreError.AuthLockedOutTemporary)
215
+ assertEquals("ERR_WALLET_AUTH_LOCKED_OUT", error.code)
216
+ }
217
+
218
+ @Test
219
+ fun classifyPromptError_lockoutPermanentIsDistinctFromTemporary() {
220
+ val error = NativeWalletStore.classifyPromptError(BiometricPrompt.ERROR_LOCKOUT_PERMANENT, "locked out for good")
221
+ assertTrue(error is NativeWalletStoreError.AuthLockedOutPermanent)
222
+ assertEquals("ERR_WALLET_AUTH_LOCKED_OUT_PERMANENT", error.code)
223
+ }
224
+
225
+ @Test
226
+ fun classifyPromptError_userCancelledVariantsAllMapToCancelled() {
227
+ val cancelCodes = listOf(
228
+ BiometricPrompt.ERROR_USER_CANCELED,
229
+ BiometricPrompt.ERROR_NEGATIVE_BUTTON,
230
+ BiometricPrompt.ERROR_CANCELED,
231
+ )
232
+ cancelCodes.forEach { code ->
233
+ val error = NativeWalletStore.classifyPromptError(code, "cancelled")
234
+ assertTrue("code $code should classify as AuthCancelled", error is NativeWalletStoreError.AuthCancelled)
235
+ assertEquals("ERR_WALLET_AUTH_CANCELLED", error.code)
236
+ }
237
+ }
238
+
239
+ @Test
240
+ fun classifyPromptError_unrecognizedCodeFallsBackToAuthenticationFailed() {
241
+ val error = NativeWalletStore.classifyPromptError(BiometricPrompt.ERROR_NO_BIOMETRICS, "no biometrics enrolled")
242
+ assertTrue(error is NativeWalletStoreError.AuthenticationFailed)
243
+ assertEquals("ERR_AUTHENTICATION_FAILED", error.code)
244
+ assertEquals(BiometricPrompt.ERROR_NO_BIOMETRICS, (error as NativeWalletStoreError.AuthenticationFailed).errorCode)
245
+ }
246
+
247
+ // ─── describeSecurityLevel / describeLegacySecurityLevel ────────────────────
248
+
249
+ @Test
250
+ fun describeSecurityLevel_mapsEachKnownLevel() {
251
+ assertEquals("STRONGBOX", NativeWalletStore.describeSecurityLevel(KeyProperties.SECURITY_LEVEL_STRONGBOX))
252
+ assertEquals("TEE", NativeWalletStore.describeSecurityLevel(KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT))
253
+ assertEquals("SOFTWARE", NativeWalletStore.describeSecurityLevel(KeyProperties.SECURITY_LEVEL_SOFTWARE))
254
+ }
255
+
256
+ @Test
257
+ fun describeSecurityLevel_unknownLevelFallsThroughToGenericMessage() {
258
+ val description = NativeWalletStore.describeSecurityLevel(-999)
259
+ assertTrue(description.contains("-999"))
260
+ // Must never silently read as hardware-backed for a level this function doesn't recognize.
261
+ assertFalse(description == "STRONGBOX")
262
+ assertFalse(description == "TEE")
263
+ }
264
+
265
+ @Test
266
+ fun describeLegacySecurityLevel_insideSecureHardwareIsHardware() {
267
+ assertEquals("HARDWARE", NativeWalletStore.describeLegacySecurityLevel(true))
268
+ }
269
+
270
+ @Test
271
+ fun describeLegacySecurityLevel_notInsideSecureHardwareIsSoftware() {
272
+ assertEquals("SOFTWARE", NativeWalletStore.describeLegacySecurityLevel(false))
273
+ }
274
+ }
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "platforms": ["ios", "android"],
3
3
  "ios": {
4
- "modules": ["TrustWalletCoreModule"]
4
+ "modules": ["ChainberryTrustWalletCoreModule"],
5
+ "podspecPath": "ChainberryTrustWalletCoreModule.podspec"
5
6
  },
6
7
  "android": {
7
- "modules": ["expo.modules.trustwalletcore.TrustWalletCoreModule"]
8
+ "modules": ["com.chainberry.trustwalletcore.ChainberryTrustWalletCoreModule"]
8
9
  }
9
10
  }
@@ -0,0 +1,62 @@
1
+ import Foundation
2
+
3
+ // Pure numeric-string parsing for the fields that used to silently substitute 0 on
4
+ // malformed input (`Int64(s) ?? 0` / `UInt64(s) ?? 0`) instead of failing closed — see
5
+ // ChainSigning.swift's signXrp/signTon. Deliberately has no WalletCore/ExpoModulesCore
6
+ // dependency so it can be unit-tested standalone via the ConformanceTests SwiftPM package,
7
+ // against the same fixture Android's AmountParsing.kt conformance test reads
8
+ // (../conformance/amount-parsing-cases.json).
9
+
10
+ enum AmountParsingError: Error, CustomStringConvertible {
11
+ case invalidXrpAmount(String)
12
+ case invalidXrpFee(String)
13
+ case invalidXrpDestinationTag
14
+ case invalidTonAmount(String)
15
+
16
+ var description: String {
17
+ switch self {
18
+ case .invalidXrpAmount(let s): return "Invalid XRP Amount: \(s)"
19
+ case .invalidXrpFee(let s): return "Invalid XRP Fee: \(s)"
20
+ case .invalidXrpDestinationTag: return "Invalid XRP DestinationTag: must be an integer 0...4294967295"
21
+ case .invalidTonAmount(let s): return "Invalid TON amount: \(s)"
22
+ }
23
+ }
24
+ }
25
+
26
+ /// XRP `Amount` (drops) must parse as a strictly positive Int64. A non-numeric, decimal,
27
+ /// empty, or negative string must throw rather than silently sign a zero-value transfer.
28
+ func parseXrpAmountDrops(_ s: String) throws -> Int64 {
29
+ guard let value = Int64(s), value > 0 else {
30
+ throw AmountParsingError.invalidXrpAmount(s)
31
+ }
32
+ return value
33
+ }
34
+
35
+ /// XRP `Fee` (drops) must parse as a non-negative Int64 (0 is a legitimate fee for some
36
+ /// transaction types — only genuinely malformed input, not zero, should throw).
37
+ func parseXrpFeeDrops(_ s: String) throws -> Int64 {
38
+ guard let value = Int64(s), value >= 0 else {
39
+ throw AmountParsingError.invalidXrpFee(s)
40
+ }
41
+ return value
42
+ }
43
+
44
+ /// XRP DestinationTag is an optional UInt32. `nil` passes through unchanged; a present
45
+ /// value must be validated as non-negative and in-range *before* widening — Swift's
46
+ /// `UInt64(someNegativeInt)` traps at runtime rather than throwing, so that conversion must
47
+ /// never run on unvalidated input.
48
+ func parseXrpDestinationTag(_ tag: Int?) throws -> UInt64? {
49
+ guard let tag else { return nil }
50
+ guard tag >= 0, tag <= 0xFFFF_FFFF else {
51
+ throw AmountParsingError.invalidXrpDestinationTag
52
+ }
53
+ return UInt64(tag)
54
+ }
55
+
56
+ /// TON `amount` (nanotons) must parse as a strictly positive UInt64.
57
+ func parseTonNanotons(_ s: String) throws -> UInt64 {
58
+ guard let value = UInt64(s), value > 0 else {
59
+ throw AmountParsingError.invalidTonAmount(s)
60
+ }
61
+ return value
62
+ }
@@ -0,0 +1,66 @@
1
+ import Foundation
2
+
3
+ /// Minimal BIP-173 (bech32) segwit-address encoder, witness-version-0 only.
4
+ ///
5
+ /// wallet-core's own `SegwitAddress` class only accepts an HRP from its closed native `HRP`
6
+ /// enum — there's no "tltc" entry (Litecoin testnet isn't in wallet-core's coin registry at
7
+ /// all; see ChainSigner.address(for:)). This reimplements just the encode half of BIP-173 by
8
+ /// hand so Litecoin testnet can still get a real native-segwit address in the same style as
9
+ /// its own mainnet "ltc1..." address, instead of falling back to a legacy P2PKH format.
10
+ ///
11
+ /// Mirrors android/.../Bech32.kt exactly — see that file's Bech32Test for the algorithm's
12
+ /// verification against a real, independently-decoded mainnet address.
13
+ enum Bech32 {
14
+ private static let charset = Array("qpzry9x8gf2tvdw0s3jn54khce6mua7l")
15
+
16
+ private static func polymod(_ values: [Int]) -> Int {
17
+ let gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
18
+ var chk = 1
19
+ for v in values {
20
+ let b = chk >> 25
21
+ chk = ((chk & 0x1ffffff) << 5) ^ v
22
+ for i in 0..<5 {
23
+ if (b >> i) & 1 == 1 { chk ^= gen[i] }
24
+ }
25
+ }
26
+ return chk
27
+ }
28
+
29
+ private static func hrpExpand(_ hrp: String) -> [Int] {
30
+ let bytes = Array(hrp.utf8).map { Int($0) }
31
+ return bytes.map { $0 >> 5 } + [0] + bytes.map { $0 & 31 }
32
+ }
33
+
34
+ private static func createChecksum(hrp: String, data: [Int]) -> [Int] {
35
+ let values = hrpExpand(hrp) + data + [0, 0, 0, 0, 0, 0]
36
+ let mod = polymod(values) ^ 1
37
+ return (0..<6).map { (mod >> (5 * (5 - $0))) & 31 }
38
+ }
39
+
40
+ /// 8-bit bytes -> 5-bit groups (BIP-173 "convertbits", 8→5, with padding).
41
+ private static func convertBits8to5(_ data: Data) -> [Int] {
42
+ var acc = 0
43
+ var bits = 0
44
+ var out: [Int] = []
45
+ for b in data {
46
+ acc = (acc << 8) | Int(b)
47
+ bits += 8
48
+ while bits >= 5 {
49
+ bits -= 5
50
+ out.append((acc >> bits) & 0x1f)
51
+ }
52
+ }
53
+ if bits > 0 { out.append((acc << (5 - bits)) & 0x1f) }
54
+ return out
55
+ }
56
+
57
+ /// Encodes a witness-version-0 program (20 bytes for P2WPKH, 32 for P2WSH) as a lowercase
58
+ /// bech32 segwit address under `hrp`.
59
+ static func encodeSegwitV0(hrp: String, program: Data) -> String {
60
+ let data = [0] + convertBits8to5(program)
61
+ let combined = data + createChecksum(hrp: hrp, data: data)
62
+ var result = hrp + "1"
63
+ for d in combined { result.append(charset[d]) }
64
+ return result
65
+ }
66
+ }