@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,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,344 @@
|
|
|
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
|
+
|
|
275
|
+
// ─── parseKeyAlias ───────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
@Test
|
|
278
|
+
fun parseKeyAlias_isTheInverseOfKeyAlias() {
|
|
279
|
+
val id = UUID.randomUUID().toString()
|
|
280
|
+
for (mode in AuthMode.entries) {
|
|
281
|
+
val alias = NativeWalletStore.keyAlias(mode, id)
|
|
282
|
+
assertEquals(mode to id, NativeWalletStore.parseKeyAlias(alias))
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
@Test
|
|
287
|
+
fun parseKeyAlias_legacyInfixDoesNotSwallowBioOrCredAliases() {
|
|
288
|
+
// Regression check: LEGACY_COMBINED's infix is "", which is a prefix of every alias under
|
|
289
|
+
// KEY_ALIAS_PREFIX — parseKeyAlias must still resolve a bio_/cred_ alias to its actual mode,
|
|
290
|
+
// not fall through to LEGACY_COMBINED just because the empty-infix check would also match.
|
|
291
|
+
val id = UUID.randomUUID().toString()
|
|
292
|
+
assertEquals(
|
|
293
|
+
AuthMode.BIOMETRIC_STRONG to id,
|
|
294
|
+
NativeWalletStore.parseKeyAlias(NativeWalletStore.keyAlias(AuthMode.BIOMETRIC_STRONG, id)),
|
|
295
|
+
)
|
|
296
|
+
assertEquals(
|
|
297
|
+
AuthMode.DEVICE_CREDENTIAL to id,
|
|
298
|
+
NativeWalletStore.parseKeyAlias(NativeWalletStore.keyAlias(AuthMode.DEVICE_CREDENTIAL, id)),
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
@Test
|
|
303
|
+
fun parseKeyAlias_unrelatedAliasReturnsNull() {
|
|
304
|
+
assertEquals(null, NativeWalletStore.parseKeyAlias("some_other_features_key"))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ─── findOrphanFiles / findStaleMetadataTempFiles (reconciliation, docs/adr/0001) ────────────
|
|
308
|
+
|
|
309
|
+
@Test
|
|
310
|
+
fun findOrphanFiles_fileWithNoMetadataEntryIsOrphaned() {
|
|
311
|
+
File(tmp.root, "orphan-id.enc").writeBytes(byteArrayOf(1))
|
|
312
|
+
val orphans = NativeWalletStore.findOrphanFiles(tmp.root, liveIds = emptySet())
|
|
313
|
+
assertEquals(listOf("orphan-id.enc"), orphans.map { it.name })
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
@Test
|
|
317
|
+
fun findOrphanFiles_fileWithMatchingMetadataEntryIsNotOrphaned() {
|
|
318
|
+
File(tmp.root, "live-id.enc").writeBytes(byteArrayOf(1))
|
|
319
|
+
val orphans = NativeWalletStore.findOrphanFiles(tmp.root, liveIds = setOf("live-id"))
|
|
320
|
+
assertTrue(orphans.isEmpty())
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
@Test
|
|
324
|
+
fun findOrphanFiles_ignoresNonEncFiles() {
|
|
325
|
+
// metadata.json itself (and any stray temp file) must never be swept up as a wallet orphan.
|
|
326
|
+
File(tmp.root, "metadata.json").writeText("{}")
|
|
327
|
+
val orphans = NativeWalletStore.findOrphanFiles(tmp.root, liveIds = emptySet())
|
|
328
|
+
assertTrue(orphans.isEmpty())
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
@Test
|
|
332
|
+
fun findStaleMetadataTempFiles_findsLeftoverTempFile() {
|
|
333
|
+
// Simulates a saveMetadataToFile interrupted after the temp write but before the rename.
|
|
334
|
+
File(tmp.root, "metadata.json.tmp-12345").writeText("{}")
|
|
335
|
+
val stale = NativeWalletStore.findStaleMetadataTempFiles(tmp.root)
|
|
336
|
+
assertEquals(listOf("metadata.json.tmp-12345"), stale.map { it.name })
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
@Test
|
|
340
|
+
fun findStaleMetadataTempFiles_ignoresCommittedMetadataFile() {
|
|
341
|
+
File(tmp.root, "metadata.json").writeText("{}")
|
|
342
|
+
assertTrue(NativeWalletStore.findStaleMetadataTempFiles(tmp.root).isEmpty())
|
|
343
|
+
}
|
|
344
|
+
}
|
package/expo-module.config.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"platforms": ["ios", "android"],
|
|
3
3
|
"ios": {
|
|
4
|
-
"modules": ["
|
|
4
|
+
"modules": ["ChainberryTrustWalletCoreModule"],
|
|
5
|
+
"podspecPath": "ChainberryTrustWalletCoreModule.podspec"
|
|
5
6
|
},
|
|
6
7
|
"android": {
|
|
7
|
-
"modules": ["
|
|
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
|
+
}
|
package/ios/Bech32.swift
ADDED
|
@@ -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
|
+
}
|