@chainberry/trust-wallet-core 2.5.1 → 2.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ChainberryTrustWalletCoreModule.podspec +6 -0
- package/README.md +30 -2
- package/android/src/androidTest/java/com/chainberry/trustwalletcore/AddressDerivationConformanceTest.kt +4 -4
- package/android/src/androidTest/java/com/chainberry/trustwalletcore/SigningConformanceTest.kt +2 -2
- package/android/src/main/java/com/chainberry/trustwalletcore/ChainSigning.kt +43 -26
- package/android/src/main/java/com/chainberry/trustwalletcore/ChainberryTrustWalletCoreModule.kt +30 -27
- package/android/src/main/java/com/chainberry/trustwalletcore/NativeWalletStore.kt +36 -18
- package/android/src/test/java/com/chainberry/trustwalletcore/NativeWalletStoreTest.kt +22 -4
- package/ios/ChainSigning.swift +35 -21
- package/ios/ChainberryTrustWalletCoreModule.swift +27 -21
- package/ios/ConformanceTests/SigningConformanceTests.swift +4 -7
- package/ios/NativeWalletStore.swift +17 -6
- package/package.json +1 -1
- package/src/index.ts +12 -12
|
@@ -19,4 +19,10 @@ Pod::Spec.new do |s|
|
|
|
19
19
|
|
|
20
20
|
s.dependency 'ExpoModulesCore'
|
|
21
21
|
s.dependency 'TrustWalletCore', '4.1.19'
|
|
22
|
+
|
|
23
|
+
s.test_spec 'ConformanceTests' do |ts|
|
|
24
|
+
ts.source_files = 'ios/ConformanceTests/*.swift'
|
|
25
|
+
ts.dependency 'TrustWalletCore', '4.1.19'
|
|
26
|
+
ts.resources = ['conformance/signing-vectors.json', 'conformance/address-derivation-vectors.json']
|
|
27
|
+
end
|
|
22
28
|
end
|
package/README.md
CHANGED
|
@@ -24,6 +24,34 @@ GitHub Packages auth is still needed (via `GITHUB_ACTOR`/`GITHUB_TOKEN` env vars
|
|
|
24
24
|
|
|
25
25
|
Android biometric gating additionally pulls in `androidx.biometric:biometric:1.1.0`.
|
|
26
26
|
|
|
27
|
+
## Publish
|
|
28
|
+
|
|
29
|
+
This package is consumed by the app via `file:modules/trust-wallet-core` (see the root
|
|
30
|
+
`package.json`), not an npm workspace under `packages/*` — so, unlike
|
|
31
|
+
`@chainberry/expo-wallet-sdk`, there's no `-w` flag to use; run these from inside this directory.
|
|
32
|
+
There's also no build step: this package ships its TypeScript source directly (`main`/`types`
|
|
33
|
+
point at `src/index.ts`), so no `prepublishOnly` rebuild happens either.
|
|
34
|
+
|
|
35
|
+
1. Bump the version (in `package.json`, or via):
|
|
36
|
+
```sh
|
|
37
|
+
cd modules/trust-wallet-core
|
|
38
|
+
npm version patch # or minor / major
|
|
39
|
+
```
|
|
40
|
+
2. Dry-run first — prints exactly what would be published without touching the registry:
|
|
41
|
+
```sh
|
|
42
|
+
npm publish --dry-run
|
|
43
|
+
```
|
|
44
|
+
3. Publish for real:
|
|
45
|
+
```sh
|
|
46
|
+
npm publish
|
|
47
|
+
```
|
|
48
|
+
`publishConfig.access: "public"` in this package's own `package.json` already covers the
|
|
49
|
+
`--access public` flag scoped packages otherwise need.
|
|
50
|
+
|
|
51
|
+
Requires npm registry publish auth (an `_authToken` for `@chainberry`, e.g. via `.npmrc` — not
|
|
52
|
+
committed to git). This is separate from the GitHub Packages auth mentioned above, which is only
|
|
53
|
+
needed for bumping the pinned Android wallet-core artifact, not for publishing this npm package.
|
|
54
|
+
|
|
27
55
|
## Usage
|
|
28
56
|
|
|
29
57
|
```ts
|
|
@@ -71,11 +99,11 @@ Both platforms pin **Trust Wallet Core 4.1.19** (`com.trustwallet:wallet-core:4.
|
|
|
71
99
|
|
|
72
100
|
**Byte-for-byte signing output is verified for 8 of 9 signable chains** in `conformance/signing-vectors.json`, asserted by `src/androidTest/.../SigningConformanceTest.kt`. Each vector calls `ChainSigner.sign()` — the same call path production code uses — with a fixed, deterministic (not necessarily broadcast-valid) unsigned tx. `ton` is verified but _not_ byte-exact-asserted: `signTon()` embeds a wall-clock `expireAt` into the signed payload, so its output legitimately differs every run — the test instead checks the output is a well-formed signed BOC. `bitcoincash` has no signing vector (sending is unsupported, see above).
|
|
73
101
|
|
|
74
|
-
Both Android suites are CI-gated on every push/PR (`.github/workflows/ci.yml`'s `android-unit-tests` and `android-instrumented-tests` jobs, via `example/` — see that folder's README for why an Expo host app is needed to build this module at all). iOS
|
|
102
|
+
Both Android suites are CI-gated on every push/PR (`.github/workflows/ci.yml`'s `android-unit-tests` and `android-instrumented-tests` jobs, via `example/` — see that folder's README for why an Expo host app is needed to build this module at all). iOS is CI-gated via the `ios-conformance-tests` job: `expo prebuild` generates the Xcode workspace, `pod install` wires up the `ConformanceTests` test_spec declared in the podspec (CocoaPods includes test_specs automatically — there is no `--include-test-specs` flag), and `xcodebuild test` runs both `AddressDerivationConformanceTests` and `SigningConformanceTests` on an iPhone 16 simulator.
|
|
75
103
|
|
|
76
104
|
**Tron divergence resolved.** iOS's `signTron` used to hand wallet-core the full `rawJson` and return its own reconstruction, diverging from Android's `txId`-only input + manual JSON reassembly — and `TronSigningInput` has no `rawJson` field in the pinned 4.1.19 anyway, so the old iOS code didn't compile. iOS now signs the same way Android does: `TronSigningInput.txID` set to the digest, signature reassembled into `{...tx, signature}` in app code (see `ios/ChainSigning.swift`'s `signTron`). Same key + same digest is guaranteed to produce the same ECDSA signature, so this is a structural fix, not something that needs a device to confirm.
|
|
77
105
|
|
|
78
|
-
**iOS conformance suites are
|
|
106
|
+
**iOS conformance suites are wired via CocoaPods `test_spec`.** `ios/ConformanceTests/AddressDerivationConformanceTests.swift` and `SigningConformanceTests.swift` mirror the Android fixtures above (all 10 address-derivation chains, the same 8 signing vectors, including a `testTron()` that asserts the reassembled signature matches Android's byte-for-byte). Both are declared as a `test_spec 'ConformanceTests'` in `ChainberryTrustWalletCoreModule.podspec`, which links them against `TrustWalletCore 4.1.19` and embeds the JSON fixtures as bundle resources. Running `pod install` from `example/ios` creates the `ChainberryTrustWalletCoreModule-ConformanceTests` scheme in the workspace automatically. The `ConformanceTests/` SwiftPM package continues to test only amount-parsing (no WalletCore dependency, runs standalone via `swift test`). The `tron` signing vector remains `"verified-android-only"` in the JSON until the iOS CI job's first green run confirms the signature matches byte-for-byte.
|
|
79
107
|
|
|
80
108
|
## License
|
|
81
109
|
|
|
@@ -44,7 +44,7 @@ class AddressDerivationConformanceTest {
|
|
|
44
44
|
|
|
45
45
|
// Chains with expected addresses confirmed against WalletCore 4.1.19, on a real device/emulator.
|
|
46
46
|
// ETH / BNB / POL all share CoinType.ETHEREUM (same secp256k1 key, BIP44 m/44'/60'/0'/0/0).
|
|
47
|
-
val
|
|
47
|
+
val verified = mapOf(
|
|
48
48
|
CoinType.ETHEREUM to "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", // ethereum / bnb / polygon
|
|
49
49
|
CoinType.SMARTCHAIN to "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", // bnb (same key as ethereum)
|
|
50
50
|
CoinType.BITCOIN to "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu",
|
|
@@ -63,13 +63,13 @@ class AddressDerivationConformanceTest {
|
|
|
63
63
|
|
|
64
64
|
// Steady state is empty — add a chain's CoinType here (and drop it from VERIFIED) only while
|
|
65
65
|
// actively harvesting a newly-added chain's address for the first time.
|
|
66
|
-
val
|
|
66
|
+
val pending = emptyList<CoinType>()
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
@Test
|
|
70
70
|
fun verifiedAddressesMatch() {
|
|
71
71
|
val wallet = HDWallet(MNEMONIC, "")
|
|
72
|
-
for ((coin, expected) in
|
|
72
|
+
for ((coin, expected) in verified) {
|
|
73
73
|
val actual = wallet.getAddressForCoin(coin)
|
|
74
74
|
assertEquals("address mismatch for coin=${coin.name}", expected, actual)
|
|
75
75
|
}
|
|
@@ -96,7 +96,7 @@ class AddressDerivationConformanceTest {
|
|
|
96
96
|
@Test
|
|
97
97
|
fun printPendingAddressesForVerification() {
|
|
98
98
|
val wallet = HDWallet(MNEMONIC, "")
|
|
99
|
-
val lines =
|
|
99
|
+
val lines = pending.map { coin -> " ${coin.name} -> ${wallet.getAddressForCoin(coin)}" }
|
|
100
100
|
println(
|
|
101
101
|
"\n[AddressDerivationConformanceTest] Pending — verify and move to VERIFIED map:\n" +
|
|
102
102
|
lines.joinToString("\n")
|
package/android/src/androidTest/java/com/chainberry/trustwalletcore/SigningConformanceTest.kt
CHANGED
|
@@ -125,7 +125,7 @@ class SigningConformanceTest {
|
|
|
125
125
|
// methodology). "ton" is deliberately absent — signTon() embeds a wall-clock
|
|
126
126
|
// `expireAt = now + 600s` into the signed payload, so its output is never byte-reproducible
|
|
127
127
|
// across runs; see tonVectorIsWellFormed() below for what's actually checked instead.
|
|
128
|
-
val
|
|
128
|
+
val expectedOutputs: Map<String, String> = mapOf(
|
|
129
129
|
"ethereum" to "0xf86c808504a817c800825208949858effd232b4033e47d90003d41ec34ecaeda94880de0b6b3a76400008026a0b7cca5f69561cd482cec4e692bd2da17d9eb1d6b3a83fe3f7f11d117de99ca97a00ad0dc153460e39b355c226a2744fc2926f84b95b89c954551571f1bbc251478",
|
|
130
130
|
"polygon" to "0x02f874818980843b9aca008509502f9000825208949858effd232b4033e47d90003d41ec34ecaeda94880de0b6b3a764000080c001a038b5c72ee38aa18607462b80ab0cb170a8d4df1c4090ac49acd41d6b4b0f9acca05bdee390ee8a50ff569f55d408371213df72a3065902b07317289b39788cb434",
|
|
131
131
|
"bitcoin" to "0100000000010100000000000000000000000000000000000000000000000000000000000000000000000000000000000250c3000000000000160014c0cebcd6c3d3ca8c75dc5ec62ebe55330ef910e2cebd000000000000160014c0cebcd6c3d3ca8c75dc5ec62ebe55330ef910e20247304402201e8f3663c97712bbf662c96df532e701ca9b660b507ab170d7b4ffbd966d4be0022033bd537c7720b1e7be2b7663d1a0adcbe565ff18a25794aec828015529ab80e101210330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c00000000",
|
|
@@ -160,7 +160,7 @@ class SigningConformanceTest {
|
|
|
160
160
|
fun signedOutputsMatch() {
|
|
161
161
|
val wallet = HDWallet(MNEMONIC, "")
|
|
162
162
|
for ((chain, pair) in vectors()) {
|
|
163
|
-
val expected =
|
|
163
|
+
val expected = expectedOutputs[chain] ?: continue
|
|
164
164
|
val (chainKey, unsignedTx) = pair
|
|
165
165
|
val actual = ChainSigner.sign(chainKey, wallet, unsignedTx, isTestnet = false).signedTx
|
|
166
166
|
assertEquals("signed output mismatch for chain=$chain", expected, actual)
|
|
@@ -13,14 +13,14 @@ import wallet.core.jni.Hash
|
|
|
13
13
|
import wallet.core.jni.PrivateKey
|
|
14
14
|
import wallet.core.jni.SolanaTransaction
|
|
15
15
|
import wallet.core.jni.TransactionDecoder
|
|
16
|
+
import wallet.core.jni.proto.Aptos
|
|
16
17
|
import wallet.core.jni.proto.Bitcoin
|
|
17
18
|
import wallet.core.jni.proto.Common
|
|
18
|
-
import wallet.core.jni.proto.Aptos
|
|
19
19
|
import wallet.core.jni.proto.Cosmos
|
|
20
|
-
import wallet.core.jni.proto.Tezos
|
|
21
20
|
import wallet.core.jni.proto.Ethereum
|
|
22
21
|
import wallet.core.jni.proto.Ripple
|
|
23
22
|
import wallet.core.jni.proto.Solana
|
|
23
|
+
import wallet.core.jni.proto.Tezos
|
|
24
24
|
import wallet.core.jni.proto.TheOpenNetwork
|
|
25
25
|
import wallet.core.jni.proto.Tron
|
|
26
26
|
import java.math.BigInteger
|
|
@@ -687,10 +687,10 @@ internal fun ChainSigner.buildSummary(chain: ChainKey, unsignedTx: Map<String, A
|
|
|
687
687
|
val firstContract = (unsignedTx["raw_data"] as? Map<String, Any>)
|
|
688
688
|
?.let { (it["contract"] as? List<Map<String, Any>>)?.firstOrNull() }
|
|
689
689
|
firstContract?.let { contract ->
|
|
690
|
-
val
|
|
690
|
+
val contractType = contract["type"] as? String ?: ""
|
|
691
691
|
val value = (contract["parameter"] as? Map<String, Any>)
|
|
692
692
|
?.let { it["value"] as? Map<String, Any> }
|
|
693
|
-
when (
|
|
693
|
+
when (contractType) {
|
|
694
694
|
"TransferContract" -> value?.let { v ->
|
|
695
695
|
(v["to_address"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
|
|
696
696
|
(v["amount"] as? Number)?.let { lines += "Amount: ${fmtAmt(it.toDouble() / 1e6)} TRX" }
|
|
@@ -710,21 +710,20 @@ internal fun ChainSigner.buildSummary(chain: ChainKey, unsignedTx: Map<String, A
|
|
|
710
710
|
lines += "Contract call: ${stripped.length / 2} bytes — review carefully"
|
|
711
711
|
}
|
|
712
712
|
}
|
|
713
|
-
else -> if (
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
(unsignedTx["txID"] as? String)?.let { txId ->
|
|
717
|
-
val rawHex = unsignedTx["raw_data_hex"] as? String
|
|
718
|
-
if (rawHex != null) {
|
|
719
|
-
val computed = java.security.MessageDigest.getInstance("SHA-256")
|
|
720
|
-
.digest(rawHex.hexToBytes()).toHex()
|
|
721
|
-
if (computed.lowercase() != txId.lowercase())
|
|
722
|
-
throw ChainSigningException("TRX txID does not match SHA256(raw_data_hex) — signing refused")
|
|
723
|
-
lines += "TxID verified ✓"
|
|
724
|
-
} else {
|
|
725
|
-
lines += "TxID: ${txId.take(16)}… (raw_data_hex absent — unverified)"
|
|
713
|
+
else -> if (contractType.isNotEmpty()) lines += "Contract type: $contractType — review carefully"
|
|
726
714
|
}
|
|
727
715
|
}
|
|
716
|
+
// Verify txID == SHA256(raw_data_hex). Both fields must be present — fail closed if either
|
|
717
|
+
// is absent so a JS caller cannot suppress the integrity check by omitting raw_data_hex.
|
|
718
|
+
val txId = unsignedTx["txID"] as? String
|
|
719
|
+
?: throw ChainSigningException("TRX txID missing — signing refused")
|
|
720
|
+
val rawHex = unsignedTx["raw_data_hex"] as? String
|
|
721
|
+
?: throw ChainSigningException("TRX raw_data_hex missing — cannot verify txID, signing refused")
|
|
722
|
+
val computed = java.security.MessageDigest.getInstance("SHA-256")
|
|
723
|
+
.digest(rawHex.hexToBytes()).toHex()
|
|
724
|
+
if (computed.lowercase() != txId.lowercase())
|
|
725
|
+
throw ChainSigningException("TRX txID does not match SHA256(raw_data_hex) — signing refused")
|
|
726
|
+
lines += "TxID verified ✓"
|
|
728
727
|
}
|
|
729
728
|
ChainKey.SOLANA -> {
|
|
730
729
|
val info = (unsignedTx["unsignedTxBase64"] as? String)?.let { decodeSolanaForSummary(it) }
|
|
@@ -740,14 +739,28 @@ internal fun ChainSigner.buildSummary(chain: ChainKey, unsignedTx: Map<String, A
|
|
|
740
739
|
if (!info.isTransfer) lines += "Non-transfer instruction — review carefully"
|
|
741
740
|
}
|
|
742
741
|
}
|
|
743
|
-
ChainKey.SOLANA -> lines += "(Solana — details verified by the network)"
|
|
744
742
|
ChainKey.BITCOINCASH -> {
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
} catch (_: Exception) {
|
|
743
|
+
// Fail closed — if descriptor is absent or unparseable we cannot show what will be signed.
|
|
744
|
+
val descriptorJson = unsignedTx["unsignedDescriptorJson"] as? String
|
|
745
|
+
?: throw ChainSigningException("Cannot decode BCH descriptor — signing refused to prevent blind signing")
|
|
746
|
+
val descriptor = try {
|
|
747
|
+
JSONObject(descriptorJson)
|
|
748
|
+
} catch (_: Exception) {
|
|
749
|
+
throw ChainSigningException("Cannot parse BCH descriptor JSON — signing refused")
|
|
750
|
+
}
|
|
751
|
+
descriptor.optString("toAddress").takeIf { it.isNotEmpty() }?.let { lines += "To: ${fmtAddr(it)}" }
|
|
752
|
+
val sendSats = descriptor.optLong("sendAmountSats", -1L)
|
|
753
|
+
if (sendSats >= 0) lines += "Amount: ${fmtAmt(sendSats.toDouble() / 1e8)} BCH"
|
|
754
|
+
descriptor.optString("changeAddress").takeIf { it.isNotEmpty() }?.let { lines += "Change to: ${fmtAddr(it)}" }
|
|
755
|
+
val spb = descriptor.optInt("satsPerByte", -1)
|
|
756
|
+
if (spb >= 0) lines += "Fee rate: $spb sat/vB"
|
|
757
|
+
@Suppress("UNCHECKED_CAST")
|
|
758
|
+
val inputTotal = (unsignedTx["inputs"] as? List<Map<String, Any>>)
|
|
759
|
+
?.mapNotNull { (it["amountSats"] as? String)?.toLongOrNull() }
|
|
760
|
+
?.fold(0L, Long::plus) ?: descriptor.optLong("inputTotalSats", 0L)
|
|
761
|
+
val changeSats = descriptor.optLong("changeAmountSats", 0L)
|
|
762
|
+
val totalFee = inputTotal - sendSats.coerceAtLeast(0L) - changeSats
|
|
763
|
+
if (totalFee > 0) lines += "Total fee: ${fmtAmt(totalFee.toDouble() / 1e8)} BCH"
|
|
751
764
|
}
|
|
752
765
|
ChainKey.COSMOS -> {
|
|
753
766
|
(unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
|
|
@@ -852,12 +865,16 @@ private fun decodeSolanaForSummary(b64: String): SolanaSummary? {
|
|
|
852
865
|
}
|
|
853
866
|
|
|
854
867
|
null
|
|
855
|
-
} catch (_: Exception) {
|
|
868
|
+
} catch (_: Exception) {
|
|
869
|
+
null
|
|
870
|
+
}
|
|
856
871
|
}
|
|
857
872
|
|
|
858
873
|
private fun txHexToDouble(hex: String): Double = try {
|
|
859
874
|
BigInteger(hex.removePrefix("0x").ifEmpty { "0" }, 16).toDouble()
|
|
860
|
-
} catch (_: NumberFormatException) {
|
|
875
|
+
} catch (_: NumberFormatException) {
|
|
876
|
+
0.0
|
|
877
|
+
}
|
|
861
878
|
|
|
862
879
|
private fun fmtAmt(value: Double): String =
|
|
863
880
|
"%.8f".format(value).trimEnd('0').trimEnd('.')
|
package/android/src/main/java/com/chainberry/trustwalletcore/ChainberryTrustWalletCoreModule.kt
CHANGED
|
@@ -7,8 +7,8 @@ import expo.modules.kotlin.exception.CodedException
|
|
|
7
7
|
import expo.modules.kotlin.functions.Coroutine
|
|
8
8
|
import expo.modules.kotlin.modules.Module
|
|
9
9
|
import expo.modules.kotlin.modules.ModuleDefinition
|
|
10
|
-
import kotlinx.coroutines.sync.Mutex
|
|
11
10
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
11
|
+
import kotlinx.coroutines.sync.Mutex
|
|
12
12
|
import wallet.core.jni.HDWallet
|
|
13
13
|
import java.util.UUID
|
|
14
14
|
import kotlin.coroutines.resume
|
|
@@ -86,19 +86,21 @@ class ChainberryTrustWalletCoreModule : Module() {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
// strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses }.
|
|
89
|
+
// strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses, isTestnet }.
|
|
90
90
|
// No BIP-39 passphrase support: signTransaction always reconstructs the wallet with an
|
|
91
91
|
// empty passphrase, so accepting one here would derive addresses from a seed different
|
|
92
92
|
// from the one actually used to sign — always pass "" to stay consistent with that.
|
|
93
93
|
// isTestnet selects the address format for BTC/LTC/BCH (see ChainSigner.addressForChain) —
|
|
94
|
-
// every other chain's address is the same on mainnet and testnet.
|
|
94
|
+
// every other chain's address is the same on mainnet and testnet. This value is persisted
|
|
95
|
+
// as immutable per-wallet metadata (NativeWalletStore.WalletRecord) — signTransaction reads
|
|
96
|
+
// it back from there instead of accepting it as a parameter, so it can never drift.
|
|
95
97
|
AsyncFunction("createWallet") Coroutine { strength: Int, isTestnet: Boolean ->
|
|
96
98
|
val wallet = HDWallet(strength, "")
|
|
97
99
|
persistNewWallet(wallet, isTestnet)
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
// One-time mnemonic exposure from JS, at import only — never retained after this call.
|
|
101
|
-
// Returns { walletId, addresses }. No BIP-39 passphrase support (see `createWallet`).
|
|
103
|
+
// Returns { walletId, addresses, isTestnet }. No BIP-39 passphrase support (see `createWallet`).
|
|
102
104
|
AsyncFunction("importWallet") Coroutine { mnemonic: String, isTestnet: Boolean ->
|
|
103
105
|
val wallet = HDWallet(mnemonic, "") // throws on invalid mnemonic
|
|
104
106
|
persistNewWallet(wallet, isTestnet)
|
|
@@ -106,8 +108,8 @@ class ChainberryTrustWalletCoreModule : Module() {
|
|
|
106
108
|
|
|
107
109
|
// Reads only the ungated metadata store — no biometric prompt.
|
|
108
110
|
AsyncFunction("listWallets") {
|
|
109
|
-
NativeWalletStore.loadMetadata(context).map { (walletId,
|
|
110
|
-
mapOf("walletId" to walletId, "addresses" to addresses)
|
|
111
|
+
NativeWalletStore.loadMetadata(context).map { (walletId, record) ->
|
|
112
|
+
mapOf("walletId" to walletId, "addresses" to record.addresses, "isTestnet" to record.isTestnet)
|
|
111
113
|
}
|
|
112
114
|
}
|
|
113
115
|
|
|
@@ -133,9 +135,11 @@ class ChainberryTrustWalletCoreModule : Module() {
|
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
// Triggers the native biometry/device-credential prompt, then signs entirely in-process.
|
|
136
|
-
// Returns { signedTx, meta? }.
|
|
137
|
-
//
|
|
138
|
-
|
|
138
|
+
// Returns { signedTx, meta? }. Network mode (mainnet/testnet) is read from the wallet's own
|
|
139
|
+
// persisted record, not accepted as a parameter — see ChainSigner.keyForChain and
|
|
140
|
+
// NativeWalletStore.WalletRecord for why a caller-supplied value here could sign with the
|
|
141
|
+
// wrong key for BTC/LTC.
|
|
142
|
+
AsyncFunction("signTransaction") Coroutine { walletId: String, chain: String, unsignedTx: Map<String, Any> ->
|
|
139
143
|
val id = NativeWalletStore.validateWalletId(walletId)
|
|
140
144
|
val chainKey = ChainKey.fromJs(chain)
|
|
141
145
|
confirmTransaction(chainKey, unsignedTx)
|
|
@@ -145,22 +149,21 @@ class ChainberryTrustWalletCoreModule : Module() {
|
|
|
145
149
|
// Backfill any addresses missing from metadata (chains added after wallet was created).
|
|
146
150
|
// Runs silently after the biometric gate — no extra prompt needed.
|
|
147
151
|
val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
|
|
148
|
-
|
|
149
|
-
val
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
if (changed) {
|
|
160
|
-
metadata[id] = storedAddresses
|
|
161
|
-
runCatching { NativeWalletStore.saveMetadata(context, metadata) }
|
|
152
|
+
val record = metadata[id] ?: throw NativeWalletStoreError.NotFound(id)
|
|
153
|
+
val isTestnet = record.isTestnet
|
|
154
|
+
val storedAddresses = record.addresses.toMutableMap()
|
|
155
|
+
var changed = false
|
|
156
|
+
for (chainEntry in ChainKey.entries) {
|
|
157
|
+
val key = chainEntry.name.lowercase()
|
|
158
|
+
if (!storedAddresses.containsKey(key)) {
|
|
159
|
+
storedAddresses[key] = ChainSigner.addressForChain(wallet, chainEntry, isTestnet)
|
|
160
|
+
changed = true
|
|
162
161
|
}
|
|
163
162
|
}
|
|
163
|
+
if (changed) {
|
|
164
|
+
metadata[id] = record.copy(addresses = storedAddresses)
|
|
165
|
+
runCatching { NativeWalletStore.saveMetadata(context, metadata) }
|
|
166
|
+
}
|
|
164
167
|
val result = ChainSigner.sign(chainKey, wallet, unsignedTx, isTestnet)
|
|
165
168
|
val response = mutableMapOf<String, Any>("signedTx" to result.signedTx)
|
|
166
169
|
result.meta?.let { response["meta"] = it }
|
|
@@ -175,8 +178,8 @@ class ChainberryTrustWalletCoreModule : Module() {
|
|
|
175
178
|
}
|
|
176
179
|
}
|
|
177
180
|
|
|
178
|
-
|
|
179
|
-
|
|
181
|
+
// Shows a native AlertDialog with decoded tx details before biometric auth fires.
|
|
182
|
+
// The user must tap "Confirm & Sign" — cancelling throws UserCancelled.
|
|
180
183
|
private suspend fun confirmTransaction(chain: ChainKey, unsignedTx: Map<String, Any>) {
|
|
181
184
|
val message = ChainSigner.buildSummary(chain, unsignedTx)
|
|
182
185
|
suspendCancellableCoroutine<Unit> { continuation ->
|
|
@@ -211,7 +214,7 @@ class ChainberryTrustWalletCoreModule : Module() {
|
|
|
211
214
|
|
|
212
215
|
try {
|
|
213
216
|
val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
|
|
214
|
-
metadata[walletId] = addresses
|
|
217
|
+
metadata[walletId] = NativeWalletStore.WalletRecord(isTestnet, addresses)
|
|
215
218
|
NativeWalletStore.saveMetadata(context, metadata)
|
|
216
219
|
} catch (e: Exception) {
|
|
217
220
|
// The mnemonic/key is already persisted but has no metadata pointer — compensate by
|
|
@@ -222,6 +225,6 @@ class ChainberryTrustWalletCoreModule : Module() {
|
|
|
222
225
|
throw e
|
|
223
226
|
}
|
|
224
227
|
|
|
225
|
-
return mapOf("walletId" to walletId, "addresses" to addresses)
|
|
228
|
+
return mapOf("walletId" to walletId, "addresses" to addresses, "isTestnet" to isTestnet)
|
|
226
229
|
}
|
|
227
230
|
}
|
|
@@ -80,8 +80,7 @@ enum class AuthMode(val aliasInfix: String) {
|
|
|
80
80
|
* Extends `CodedException` directly (rather than a flat `Exception`) so `.code` survives the
|
|
81
81
|
* Expo bridge losslessly with no extra wrapping step.
|
|
82
82
|
*/
|
|
83
|
-
sealed class NativeWalletStoreError private constructor(code: String, message: String, cause: Throwable? = null) :
|
|
84
|
-
CodedException(code, message, cause) {
|
|
83
|
+
sealed class NativeWalletStoreError private constructor(code: String, message: String, cause: Throwable? = null) : CodedException(code, message, cause) {
|
|
85
84
|
|
|
86
85
|
class NotFound(walletId: String) :
|
|
87
86
|
NativeWalletStoreError("ERR_WALLET_NOT_FOUND", "Wallet not found: $walletId")
|
|
@@ -542,17 +541,27 @@ object NativeWalletStore {
|
|
|
542
541
|
}
|
|
543
542
|
}
|
|
544
543
|
|
|
545
|
-
// MARK: - Metadata (ungated: walletId -> { chain: address })
|
|
544
|
+
// MARK: - Metadata (ungated: walletId -> { isTestnet, addresses: { chain: address } })
|
|
545
|
+
|
|
546
|
+
/** A wallet's network mode is fixed at creation time ([ChainberryTrustWalletCoreModule
|
|
547
|
+
* .persistNewWallet]) and never changes thereafter — [isTestnet] is the authoritative value
|
|
548
|
+
* `signTransaction` must derive/sign with, replacing the old pattern of accepting it fresh as
|
|
549
|
+
* a parameter on every call (see docs/adr and the security remediation this type was
|
|
550
|
+
* introduced for). */
|
|
551
|
+
data class WalletRecord(val isTestnet: Boolean, val addresses: Map<String, String>)
|
|
546
552
|
|
|
547
553
|
/** Atomically replaces [target] via a temp-file write + `File.renameTo` (an atomic
|
|
548
554
|
* `rename(2)` on the same filesystem/mount, since the temp file is created alongside
|
|
549
555
|
* [target] in the same directory) — never a direct in-place overwrite, which could leave a
|
|
550
556
|
* torn file if the process is killed mid-write. Pure/`File`-based so it's unit-testable on
|
|
551
557
|
* the plain JVM without an Android `Context`. */
|
|
552
|
-
internal fun saveMetadataToFile(target: File, wallets: Map<String,
|
|
558
|
+
internal fun saveMetadataToFile(target: File, wallets: Map<String, WalletRecord>) {
|
|
553
559
|
val root = JSONObject()
|
|
554
|
-
for ((walletId,
|
|
555
|
-
|
|
560
|
+
for ((walletId, record) in wallets) {
|
|
561
|
+
val entry = JSONObject()
|
|
562
|
+
entry.put("isTestnet", record.isTestnet)
|
|
563
|
+
entry.put("addresses", JSONObject(record.addresses as Map<*, *>))
|
|
564
|
+
root.put(walletId, entry)
|
|
556
565
|
}
|
|
557
566
|
val temp = File(target.parentFile, "$METADATA_FILE.tmp-${System.nanoTime()}")
|
|
558
567
|
try {
|
|
@@ -569,32 +578,41 @@ object NativeWalletStore {
|
|
|
569
578
|
|
|
570
579
|
/** Distinguishes "no metadata has ever been written" (legitimately empty) from a genuine
|
|
571
580
|
* parse/corruption failure, which now throws a typed [NativeWalletStoreError.Corrupted]
|
|
572
|
-
* instead of letting a raw, uncaught `JSONException
|
|
573
|
-
*
|
|
574
|
-
|
|
581
|
+
* instead of letting a raw, uncaught `JSONException`/[org.json.JSONException] leak through
|
|
582
|
+
* the Expo bridge. A record missing `isTestnet` or `addresses` (e.g. data written by a
|
|
583
|
+
* pre-migration build under the old flat schema) is treated the same way — corrupted, not
|
|
584
|
+
* silently reinterpreted — there is no legacy-shape fallback. Pure/`File`-based so it's
|
|
585
|
+
* unit-testable on the plain JVM without an Android `Context`. */
|
|
586
|
+
internal fun loadMetadataFromFile(file: File): Map<String, WalletRecord> {
|
|
575
587
|
if (!file.exists()) return emptyMap()
|
|
576
588
|
val root = try {
|
|
577
589
|
JSONObject(file.readText())
|
|
578
590
|
} catch (e: JSONException) {
|
|
579
591
|
throw NativeWalletStoreError.Corrupted("metadata.json is not valid JSON", e)
|
|
580
592
|
}
|
|
581
|
-
val result = mutableMapOf<String,
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
593
|
+
val result = mutableMapOf<String, WalletRecord>()
|
|
594
|
+
try {
|
|
595
|
+
for (walletId in root.keys()) {
|
|
596
|
+
val entry = root.getJSONObject(walletId)
|
|
597
|
+
val isTestnet = entry.getBoolean("isTestnet")
|
|
598
|
+
val addressesJson = entry.getJSONObject("addresses")
|
|
599
|
+
val addresses = mutableMapOf<String, String>()
|
|
600
|
+
for (chain in addressesJson.keys()) {
|
|
601
|
+
addresses[chain] = addressesJson.getString(chain)
|
|
602
|
+
}
|
|
603
|
+
result[walletId] = WalletRecord(isTestnet, addresses)
|
|
587
604
|
}
|
|
588
|
-
|
|
605
|
+
} catch (e: JSONException) {
|
|
606
|
+
throw NativeWalletStoreError.Corrupted("metadata.json entry has an unexpected shape", e)
|
|
589
607
|
}
|
|
590
608
|
return result
|
|
591
609
|
}
|
|
592
610
|
|
|
593
|
-
fun saveMetadata(context: Context, wallets: Map<String,
|
|
611
|
+
fun saveMetadata(context: Context, wallets: Map<String, WalletRecord>) {
|
|
594
612
|
saveMetadataToFile(metadataFile(context), wallets)
|
|
595
613
|
}
|
|
596
614
|
|
|
597
|
-
fun loadMetadata(context: Context): Map<String,
|
|
615
|
+
fun loadMetadata(context: Context): Map<String, WalletRecord> {
|
|
598
616
|
return loadMetadataFromFile(metadataFile(context))
|
|
599
617
|
}
|
|
600
618
|
|
|
@@ -69,13 +69,28 @@ class NativeWalletStoreTest {
|
|
|
69
69
|
fun metadata_roundTrips() {
|
|
70
70
|
val file = File(tmp.root, "metadata.json")
|
|
71
71
|
val wallets = mapOf(
|
|
72
|
-
"id-1" to mapOf("ethereum" to "0xabc"),
|
|
73
|
-
"id-2" to mapOf("bitcoin" to "bc1abc"),
|
|
72
|
+
"id-1" to NativeWalletStore.WalletRecord(isTestnet = false, addresses = mapOf("ethereum" to "0xabc")),
|
|
73
|
+
"id-2" to NativeWalletStore.WalletRecord(isTestnet = true, addresses = mapOf("bitcoin" to "bc1abc")),
|
|
74
74
|
)
|
|
75
75
|
NativeWalletStore.saveMetadataToFile(file, wallets)
|
|
76
76
|
assertEquals(wallets, NativeWalletStore.loadMetadataFromFile(file))
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
@Test
|
|
80
|
+
fun metadata_legacyFlatShapeThrowsCorrupted_notSilentlyMisread() {
|
|
81
|
+
// Data written by a pre-migration build (walletId -> {chain: address} with no
|
|
82
|
+
// isTestnet/addresses wrapper) must never be silently reinterpreted — there is no
|
|
83
|
+
// migration path, so this must surface as Corrupted, same as any other unexpected shape.
|
|
84
|
+
val file = File(tmp.root, "metadata.json")
|
|
85
|
+
file.writeText("""{"id-1":{"ethereum":"0xabc"}}""")
|
|
86
|
+
try {
|
|
87
|
+
NativeWalletStore.loadMetadataFromFile(file)
|
|
88
|
+
fail("expected Corrupted for legacy flat-shape metadata")
|
|
89
|
+
} catch (e: NativeWalletStoreError.Corrupted) {
|
|
90
|
+
// expected
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
79
94
|
@Test
|
|
80
95
|
fun metadata_missingFileIsEmptyMap() {
|
|
81
96
|
val file = File(tmp.root, "does-not-exist.json")
|
|
@@ -99,7 +114,7 @@ class NativeWalletStoreTest {
|
|
|
99
114
|
@Test
|
|
100
115
|
fun metadata_failedWrite_leavesExistingFileUntouched() {
|
|
101
116
|
val target = File(tmp.root, "metadata.json")
|
|
102
|
-
val original = mapOf("id-1" to mapOf("ethereum" to "0xabc"))
|
|
117
|
+
val original = mapOf("id-1" to NativeWalletStore.WalletRecord(isTestnet = false, addresses = mapOf("ethereum" to "0xabc")))
|
|
103
118
|
NativeWalletStore.saveMetadataToFile(target, original)
|
|
104
119
|
|
|
105
120
|
// Making the parent directory read-only blocks creating the temp file at all (the
|
|
@@ -111,7 +126,10 @@ class NativeWalletStoreTest {
|
|
|
111
126
|
target.parentFile!!.setWritable(false)
|
|
112
127
|
try {
|
|
113
128
|
try {
|
|
114
|
-
NativeWalletStore.saveMetadataToFile(
|
|
129
|
+
NativeWalletStore.saveMetadataToFile(
|
|
130
|
+
target,
|
|
131
|
+
mapOf("id-2" to NativeWalletStore.WalletRecord(isTestnet = false, addresses = mapOf("bitcoin" to "bc1xyz"))),
|
|
132
|
+
)
|
|
115
133
|
// Some filesystems/CI runners ignore setWritable(false) for the owner (e.g. root).
|
|
116
134
|
// If the write unexpectedly succeeded, there's nothing to assert here.
|
|
117
135
|
} catch (e: NativeWalletStoreError.PermissionDenied) {
|
package/ios/ChainSigning.swift
CHANGED
|
@@ -779,21 +779,23 @@ enum ChainSigner {
|
|
|
779
779
|
}
|
|
780
780
|
}
|
|
781
781
|
}
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
782
|
+
// Verify txID == SHA256(raw_data_hex). raw_data_hex must be present — fail closed if absent
|
|
783
|
+
// so a JS caller cannot suppress the integrity check by omitting the field.
|
|
784
|
+
guard let txID = unsignedTx["txID"] as? String else {
|
|
785
|
+
throw Exception(name: "TxIntegrityFailed", description: "TRX txID missing — signing refused")
|
|
786
|
+
}
|
|
787
|
+
guard let rawHex = unsignedTx["raw_data_hex"] as? String,
|
|
788
|
+
let rawBytes = hexData(rawHex) else {
|
|
789
|
+
throw Exception(name: "TxIntegrityFailed",
|
|
790
|
+
description: "TRX raw_data_hex missing — cannot verify txID, signing refused")
|
|
791
|
+
}
|
|
792
|
+
let computed = Hash.sha256(data: rawBytes)
|
|
793
|
+
let computedHex = computed.map { String(format: "%02x", $0) }.joined()
|
|
794
|
+
guard computedHex.lowercased() == txID.lowercased() else {
|
|
795
|
+
throw Exception(name: "TxIntegrityFailed",
|
|
796
|
+
description: "TRX txID does not match SHA256(raw_data_hex) — signing refused")
|
|
796
797
|
}
|
|
798
|
+
lines.append("TxID verified ✓")
|
|
797
799
|
|
|
798
800
|
case .solana:
|
|
799
801
|
// Decode the pre-built tx to extract recipient and lamports (SOL) or destination and
|
|
@@ -812,14 +814,26 @@ enum ChainSigner {
|
|
|
812
814
|
}
|
|
813
815
|
|
|
814
816
|
case .bitcoincash:
|
|
815
|
-
if
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
}
|
|
817
|
+
// Fail closed — if descriptor is absent or unparseable we cannot show what will be signed.
|
|
818
|
+
guard let descriptorJson = unsignedTx["unsignedDescriptorJson"] as? String,
|
|
819
|
+
let data = descriptorJson.data(using: .utf8),
|
|
820
|
+
let descriptor = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
821
|
+
throw Exception(name: "UndecodableTx",
|
|
822
|
+
description: "Cannot decode BCH descriptor — signing refused to prevent blind signing")
|
|
822
823
|
}
|
|
824
|
+
if let to = descriptor["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
|
|
825
|
+
if let sats = (descriptor["sendAmountSats"] as? NSNumber)?.int64Value {
|
|
826
|
+
lines.append("Amount: \(fmtAmt(Double(sats) / 1e8)) BCH")
|
|
827
|
+
}
|
|
828
|
+
if let change = descriptor["changeAddress"] as? String { lines.append("Change to: \(fmtAddr(change))") }
|
|
829
|
+
if let spb = (descriptor["satsPerByte"] as? NSNumber)?.intValue { lines.append("Fee rate: \(spb) sat/vB") }
|
|
830
|
+
let bchInputs = (descriptor["inputs"] as? [[String: Any]])?
|
|
831
|
+
.compactMap { ($0["amountSats"] as? NSNumber)?.int64Value }
|
|
832
|
+
.reduce(Int64(0), +) ?? 0
|
|
833
|
+
let bchSend = (descriptor["sendAmountSats"] as? NSNumber)?.int64Value ?? 0
|
|
834
|
+
let bchChange = (descriptor["changeAmountSats"] as? NSNumber)?.int64Value ?? 0
|
|
835
|
+
let bchFee = bchInputs - bchSend - bchChange
|
|
836
|
+
if bchFee > 0 { lines.append("Total fee: \(fmtAmt(Double(bchFee) / 1e8)) BCH") }
|
|
823
837
|
|
|
824
838
|
case .cosmos:
|
|
825
839
|
if let to = unsignedTx["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
|
|
@@ -19,12 +19,14 @@ public class ChainberryTrustWalletCoreModule: Module {
|
|
|
19
19
|
NativeWalletStore.reconcileOrphans()
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
// strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses }.
|
|
22
|
+
// strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses, isTestnet }.
|
|
23
23
|
// No BIP-39 passphrase support: signTransaction always reconstructs the wallet with an
|
|
24
24
|
// empty passphrase, so accepting one here would derive addresses from a seed different
|
|
25
25
|
// from the one actually used to sign — always pass "" to stay consistent with that.
|
|
26
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.
|
|
27
|
+
// every other chain's address is the same on mainnet and testnet. This value is persisted
|
|
28
|
+
// as immutable per-wallet metadata (NativeWalletStore.WalletRecord) — signTransaction reads
|
|
29
|
+
// it back from there instead of accepting it as a parameter, so it can never drift.
|
|
28
30
|
AsyncFunction("createWallet") { (strength: Int, isTestnet: Bool) throws -> [String: Any] in
|
|
29
31
|
guard let wallet = HDWallet(strength: Int32(strength), passphrase: "") else {
|
|
30
32
|
throw Exception(name: "WalletError", description: "Failed to generate wallet")
|
|
@@ -37,7 +39,7 @@ public class ChainberryTrustWalletCoreModule: Module {
|
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
// 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`).
|
|
42
|
+
// Returns { walletId, addresses, isTestnet }. No BIP-39 passphrase support (see `createWallet`).
|
|
41
43
|
AsyncFunction("importWallet") { (mnemonic: String, isTestnet: Bool) throws -> [String: Any] in
|
|
42
44
|
guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else {
|
|
43
45
|
throw Exception(name: "InvalidMnemonic", description: "Invalid mnemonic phrase")
|
|
@@ -52,8 +54,8 @@ public class ChainberryTrustWalletCoreModule: Module {
|
|
|
52
54
|
// Reads only the ungated metadata store — no biometric prompt.
|
|
53
55
|
AsyncFunction("listWallets") { () throws -> [[String: Any]] in
|
|
54
56
|
do {
|
|
55
|
-
return try NativeWalletStore.loadMetadata().map { walletId,
|
|
56
|
-
["walletId": walletId, "addresses": addresses]
|
|
57
|
+
return try NativeWalletStore.loadMetadata().map { walletId, record in
|
|
58
|
+
["walletId": walletId, "addresses": record.addresses, "isTestnet": record.isTestnet]
|
|
57
59
|
}
|
|
58
60
|
} catch let e as NativeWalletStoreError {
|
|
59
61
|
throw e.asException
|
|
@@ -86,9 +88,11 @@ public class ChainberryTrustWalletCoreModule: Module {
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
// Triggers the native biometry/passcode prompt, then signs entirely in-process.
|
|
89
|
-
// Returns { signedTx, meta? }.
|
|
90
|
-
//
|
|
91
|
-
|
|
91
|
+
// Returns { signedTx, meta? }. Network mode (mainnet/testnet) is read from the wallet's own
|
|
92
|
+
// persisted record, not accepted as a parameter — see ChainSigner.key(for:) and
|
|
93
|
+
// NativeWalletStore.WalletRecord for why a caller-supplied value here could sign with the
|
|
94
|
+
// wrong key for BTC/LTC.
|
|
95
|
+
AsyncFunction("signTransaction") { (walletId: String, chain: String, unsignedTx: [String: Any]) async throws -> [String: Any] in
|
|
92
96
|
do {
|
|
93
97
|
let id = try NativeWalletStore.validateWalletId(walletId)
|
|
94
98
|
let chainKey = try ChainKey(fromJs: chain)
|
|
@@ -102,19 +106,21 @@ public class ChainberryTrustWalletCoreModule: Module {
|
|
|
102
106
|
// (e.g. chains added after the wallet was created). Runs silently after the
|
|
103
107
|
// biometric gate — no extra prompt needed.
|
|
104
108
|
var metadata = try NativeWalletStore.loadMetadata()
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
metadata[id] = storedAddresses
|
|
115
|
-
try? NativeWalletStore.saveMetadata(metadata)
|
|
109
|
+
guard var record = metadata[id] else {
|
|
110
|
+
throw NativeWalletStoreError.notFound(walletId: id)
|
|
111
|
+
}
|
|
112
|
+
let isTestnet = record.isTestnet
|
|
113
|
+
var changed = false
|
|
114
|
+
for chain in ChainKey.allCases {
|
|
115
|
+
if record.addresses[chain.rawValue] == nil {
|
|
116
|
+
record.addresses[chain.rawValue] = ChainSigner.address(for: chain, wallet: wallet, isTestnet: isTestnet)
|
|
117
|
+
changed = true
|
|
116
118
|
}
|
|
117
119
|
}
|
|
120
|
+
if changed {
|
|
121
|
+
metadata[id] = record
|
|
122
|
+
try? NativeWalletStore.saveMetadata(metadata)
|
|
123
|
+
}
|
|
118
124
|
let result = try ChainSigner.sign(chain: chainKey, wallet: wallet, unsignedTx: unsignedTx, isTestnet: isTestnet)
|
|
119
125
|
var response: [String: Any] = ["signedTx": result.signedTx]
|
|
120
126
|
if let meta = result.meta { response["meta"] = meta }
|
|
@@ -248,7 +254,7 @@ public class ChainberryTrustWalletCoreModule: Module {
|
|
|
248
254
|
try NativeWalletStore.saveMnemonic(wallet.mnemonic, walletId: walletId)
|
|
249
255
|
do {
|
|
250
256
|
var metadata = try NativeWalletStore.loadMetadata()
|
|
251
|
-
metadata[walletId] = addresses
|
|
257
|
+
metadata[walletId] = NativeWalletStore.WalletRecord(isTestnet: isTestnet, addresses: addresses)
|
|
252
258
|
try NativeWalletStore.saveMetadata(metadata)
|
|
253
259
|
} catch {
|
|
254
260
|
// The mnemonic is already persisted but has no metadata pointer — compensate by
|
|
@@ -259,7 +265,7 @@ public class ChainberryTrustWalletCoreModule: Module {
|
|
|
259
265
|
throw error
|
|
260
266
|
}
|
|
261
267
|
|
|
262
|
-
return ["walletId": walletId, "addresses": addresses]
|
|
268
|
+
return ["walletId": walletId, "addresses": addresses, "isTestnet": isTestnet]
|
|
263
269
|
}
|
|
264
270
|
|
|
265
271
|
/// Prompts biometry-or-device-passcode via `.deviceOwnerAuthentication` (Apple's
|
|
@@ -43,13 +43,10 @@ class SigningConformanceTests: XCTestCase {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
private func loadFixture() throws -> FixtureFile {
|
|
46
|
-
let
|
|
47
|
-
|
|
48
|
-
.
|
|
49
|
-
|
|
50
|
-
.deletingLastPathComponent()
|
|
51
|
-
.appendingPathComponent("conformance/signing-vectors.json")
|
|
52
|
-
.standardizedFileURL
|
|
46
|
+
guard let url = Bundle(for: SigningConformanceTests.self)
|
|
47
|
+
.url(forResource: "signing-vectors", withExtension: "json") else {
|
|
48
|
+
throw XCTSkip("signing-vectors.json not found in test bundle")
|
|
49
|
+
}
|
|
53
50
|
return try JSONDecoder().decode(FixtureFile.self, from: Data(contentsOf: url))
|
|
54
51
|
}
|
|
55
52
|
|
|
@@ -173,15 +173,24 @@ enum NativeWalletStore {
|
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
// MARK: - Metadata (ungated: walletId -> { chain: address })
|
|
176
|
+
// MARK: - Metadata (ungated: walletId -> { isTestnet, addresses: { chain: address } })
|
|
177
|
+
|
|
178
|
+
/// A wallet's network mode is fixed at creation time (`ChainberryTrustWalletCoreModule
|
|
179
|
+
/// .persistNewWallet`) and never changes thereafter — `isTestnet` is the authoritative value
|
|
180
|
+
/// `signTransaction` must derive/sign with, replacing the old pattern of accepting it fresh as
|
|
181
|
+
/// a parameter on every call.
|
|
182
|
+
struct WalletRecord: Codable {
|
|
183
|
+
var isTestnet: Bool
|
|
184
|
+
var addresses: [String: String]
|
|
185
|
+
}
|
|
177
186
|
|
|
178
187
|
/// Atomically replaces the metadata blob via `SecItemUpdate` when it already exists,
|
|
179
188
|
/// falling back to `SecItemAdd` only on the very first write. Unlike the mnemonic item,
|
|
180
189
|
/// the metadata item carries no `SecAccessControl`, so there's no access-control-change
|
|
181
190
|
/// obstacle to updating in place — this removes the delete-then-add race window entirely
|
|
182
191
|
/// (a crash between delete and add used to be able to lose the index outright).
|
|
183
|
-
static func saveMetadata(_ wallets: [String:
|
|
184
|
-
let data = try
|
|
192
|
+
static func saveMetadata(_ wallets: [String: WalletRecord]) throws {
|
|
193
|
+
let data = try JSONEncoder().encode(wallets)
|
|
185
194
|
let baseQuery: [String: Any] = [
|
|
186
195
|
kSecClass as String: kSecClassGenericPassword,
|
|
187
196
|
kSecAttrService as String: metadataService,
|
|
@@ -208,8 +217,10 @@ enum NativeWalletStore {
|
|
|
208
217
|
/// from a genuine read/corruption failure, which now throws instead of being silently
|
|
209
218
|
/// masked as an empty map. Masking it was the root cause of the "transient read failure
|
|
210
219
|
/// followed by createWallet overwrites the index" scenario: a real failure here must abort
|
|
211
|
-
/// the caller, not look identical to "no wallets yet".
|
|
212
|
-
|
|
220
|
+
/// the caller, not look identical to "no wallets yet". Data written by a pre-migration build
|
|
221
|
+
/// (walletId -> {chain: address} with no isTestnet/addresses wrapper) fails to decode and is
|
|
222
|
+
/// treated the same as any other corruption — there is no legacy-shape fallback.
|
|
223
|
+
static func loadMetadata() throws -> [String: WalletRecord] {
|
|
213
224
|
let query: [String: Any] = [
|
|
214
225
|
kSecClass as String: kSecClassGenericPassword,
|
|
215
226
|
kSecAttrService as String: metadataService,
|
|
@@ -224,7 +235,7 @@ enum NativeWalletStore {
|
|
|
224
235
|
guard status == errSecSuccess, let data = result as? Data else {
|
|
225
236
|
throw classify(status)
|
|
226
237
|
}
|
|
227
|
-
guard let obj = try?
|
|
238
|
+
guard let obj = try? JSONDecoder().decode([String: WalletRecord].self, from: data) else {
|
|
228
239
|
throw NativeWalletStoreError.corrupted("metadata JSON is unreadable")
|
|
229
240
|
}
|
|
230
241
|
return obj
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chainberry/trust-wallet-core",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.2",
|
|
4
4
|
"description": "Expo native module wrapping Trust Wallet Core for native-only HD wallet custody, address derivation, and transaction signing (Ethereum, BNB, Polygon, Solana, Tron, TON, Bitcoin, Bitcoin Cash, Litecoin, XRP) — mnemonic/private keys never cross the JS bridge",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,9 @@ export type Chain =
|
|
|
31
31
|
export type WalletSummary = {
|
|
32
32
|
walletId: string;
|
|
33
33
|
addresses: Record<Chain, string>;
|
|
34
|
+
/** Fixed at creation time and persisted as immutable native wallet metadata — the
|
|
35
|
+
* authoritative source `signTransaction` derives/signs with for this wallet. */
|
|
36
|
+
isTestnet: boolean;
|
|
34
37
|
};
|
|
35
38
|
|
|
36
39
|
export type SignResult = {
|
|
@@ -46,7 +49,9 @@ const TrustWalletCore = requireNativeModule("TrustWalletCore");
|
|
|
46
49
|
* would derive addresses from a seed different from the one actually used to sign.
|
|
47
50
|
* `isTestnet` selects the address format for BTC/LTC/BCH (every other chain's address is
|
|
48
51
|
* identical on mainnet and testnet) — callers should pass `IS_TESTNET` from
|
|
49
|
-
* `@/constants/wallet-env`.
|
|
52
|
+
* `@/constants/wallet-env`. The value passed here is permanent: it's persisted as immutable
|
|
53
|
+
* per-wallet metadata and later read back by `signTransaction` for this wallet — it cannot be
|
|
54
|
+
* changed or overridden after creation. */
|
|
50
55
|
export async function createWallet(
|
|
51
56
|
strength: 128 | 256 = 128,
|
|
52
57
|
isTestnet = false,
|
|
@@ -56,7 +61,7 @@ export async function createWallet(
|
|
|
56
61
|
|
|
57
62
|
/** One-time mnemonic exposure from the caller — persisted natively immediately, never
|
|
58
63
|
* retained in JS after this call returns. No BIP-39 passphrase support (see `createWallet`).
|
|
59
|
-
* `isTestnet` — see `createWallet
|
|
64
|
+
* `isTestnet` — see `createWallet` (same permanence guarantee applies). */
|
|
60
65
|
export async function importWallet(
|
|
61
66
|
mnemonic: string,
|
|
62
67
|
isTestnet = false,
|
|
@@ -74,21 +79,16 @@ export async function deleteWallet(walletId: string): Promise<void> {
|
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
/** Triggers the native biometry/passcode prompt, then signs entirely in-process —
|
|
77
|
-
* only signed transaction bytes/hex cross back.
|
|
78
|
-
*
|
|
79
|
-
* `
|
|
82
|
+
* only signed transaction bytes/hex cross back. Network mode (mainnet/testnet) is not
|
|
83
|
+
* accepted here: the native module reads it from the wallet's own persisted record (set once,
|
|
84
|
+
* at `createWallet`/`importWallet` time), so it can never drift from what created the
|
|
85
|
+
* wallet's addresses. */
|
|
80
86
|
export async function signTransaction(
|
|
81
87
|
walletId: string,
|
|
82
88
|
chain: Chain,
|
|
83
89
|
unsignedTx: Record<string, unknown>,
|
|
84
|
-
isTestnet = false,
|
|
85
90
|
): Promise<SignResult> {
|
|
86
|
-
return TrustWalletCore.signTransaction(
|
|
87
|
-
walletId,
|
|
88
|
-
chain,
|
|
89
|
-
unsignedTx,
|
|
90
|
-
isTestnet,
|
|
91
|
-
);
|
|
91
|
+
return TrustWalletCore.signTransaction(walletId, chain, unsignedTx);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/** The one sanctioned mnemonic exposure — explicit backup/reveal flow only, gated behind
|