@chainberry/trust-wallet-core 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Expo native module wrapping [Trust Wallet Core](https://github.com/trustwallet/wallet-core) for HD wallet generation, address derivation, and transaction signing. Runs the real native library (Kotlin/JNI on Android, Swift on iOS) — not the WASM build, so it works fine under Hermes.
4
4
 
5
- Supported coins: Ethereum, Solana, BNB Smart Chain (see `SupportedCoin`).
5
+ Supported chains: Ethereum, BNB Smart Chain, Polygon, Solana, Tron, TON, Bitcoin, Bitcoin Cash (address derivation only — see Security model), Litecoin, XRP (see `Chain`).
6
6
 
7
7
  ## Installation
8
8
 
@@ -35,30 +35,45 @@ Generate a token at github.com/settings/tokens → "classic" → check `read:pac
35
35
 
36
36
  ### iOS
37
37
 
38
- `WalletCore` is pulled in via CocoaPods (`s.dependency 'TrustWalletCore'`) — no extra auth needed, `pod install` handles it.
38
+ `WalletCore` is pulled in via CocoaPods (`s.dependency 'TrustWalletCore'`) — no extra auth needed, `pod install` handles it. Biometric/passcode gating uses `LocalAuthentication` (system framework, no extra dependency).
39
+
40
+ Android biometric gating additionally pulls in `androidx.biometric:biometric:1.1.0`.
39
41
 
40
42
  ## Usage
41
43
 
42
44
  ```ts
43
- import { generateWallet, restoreWallet, getAddressForCoin } from "@chainberry/trust-wallet-core";
45
+ import { createWallet, importWallet, signTransaction, exportMnemonic } from "@chainberry/trust-wallet-core";
46
+
47
+ const { walletId, addresses } = await createWallet(); // 128-bit / 12-word by default
48
+ // addresses: { ethereum: "0x...", solana: "...", bnb: "0x...", bitcoin: "...", ... }
44
49
 
45
- const { mnemonic, wallets } = await generateWallet(); // 128-bit / 12-word by default
46
- // wallets: { ethereum: "0x...", solana: "...", bnb: "0x..." }
50
+ const restored = await importWallet(mnemonic);
47
51
 
48
- const restored = await restoreWallet(mnemonic);
52
+ // Triggers a native biometry/passcode prompt; only signed tx bytes/hex cross back to JS.
53
+ const { signedTx } = await signTransaction(walletId, "ethereum", unsignedTx);
49
54
 
50
- const { address, privateKey } = await getAddressForCoin(mnemonic, "ethereum");
55
+ // Explicit backup flow only biometry/passcode gated.
56
+ const mnemonic = await exportMnemonic(walletId);
51
57
  ```
52
58
 
53
59
  ## API
54
60
 
55
- - `generateWallet(strength = 128, passphrase = "")` — creates a new BIP-39 mnemonic (128 = 12 words, 256 = 24 words) and returns it along with the derived address for every supported coin.
56
- - `restoreWallet(mnemonic, passphrase = "")` — validates an existing mnemonic and returns the same shape as `generateWallet`. Throws if the mnemonic is invalid.
57
- - `getAddressForCoin(mnemonic, coin, passphrase = "")` — derives `{ address, privateKey }` for a single coin (`"ethereum" | "solana" | "bnb"`).
61
+ - `createWallet(strength = 128, passphrase = "")` — generates a new BIP-39 mnemonic and persists it natively (Keychain on iOS / Keystore-backed file on Android, biometry-or-passcode gated). Returns `{ walletId, addresses }` the mnemonic itself never leaves native code.
62
+ - `importWallet(mnemonic, passphrase = "")` — validates and persists an existing mnemonic the same way. The `mnemonic` argument is a one-time exposure from the caller (e.g. a text-entry backup-restore screen); discard your own copy immediately after this call resolves.
63
+ - `listWallets()` — returns `{ walletId, addresses }[]` for every persisted wallet, reading only the ungated metadata store. No biometric prompt.
64
+ - `deleteWallet(walletId)` — removes the wallet's native key material and metadata entry. Irreversible; not biometric-gated (deleting reveals nothing, so this is a UX confirmation concern, not a key-secrecy one).
65
+ - `signTransaction(walletId, chain, unsignedTx)` — triggers a native biometry/passcode prompt, then derives the key and signs entirely inside native code. Returns `{ signedTx, meta? }`; `meta` currently only carries TON's `txHash`.
66
+ - `exportMnemonic(walletId)` — the one sanctioned mnemonic exposure. Biometry/passcode gated. Use only for an explicit "reveal recovery phrase" backup screen; don't hold the result in app state beyond that screen's lifetime.
67
+
68
+ ## Security model
69
+
70
+ Mnemonic and derived private keys are generated, persisted, and used for signing entirely inside this module's native code (Swift/Kotlin) — they are never serialized back across the JS bridge, with the single exception of `exportMnemonic`'s explicit backup flow. This is a deliberate change from this module's earlier version, which returned raw private keys to JS on every address derivation; that let a compromised/malicious JS dependency, an attached JS debugger, or a JS-heap memory dump read wallet secrets in full. Now the JS runtime never holds them at all.
71
+
72
+ Storage: one biometry-or-passcode-gated secret per wallet (`SecAccessControl` + iOS Keychain; a hardware-backed Android Keystore AES key gating an encrypted on-disk file), plus a separate, ungated metadata entry (`walletId` → addresses) for read-only UI that shouldn't need a biometric prompt just to show an address or balance.
58
73
 
59
- ## Security note
74
+ Bitcoin Cash is derivation-only: sending BCH is unsupported both here and upstream (`chainberry-wallet`'s self-custody transaction preparation has no BCH case), so this module only derives its address.
60
75
 
61
- `getAddressForCoin` returns the raw private key as hex to JS. That's a deliberate tradeoff for this module it does no key storage or signing orchestration itself, it only derives keys. Callers are responsible for how the mnemonic and private keys are held, encrypted at rest, and cleared from memory. Don't treat this module as a secure enclave; it isn't one.
76
+ **Not yet verified against a real device/build**: the per-chain `SigningInput` field mappings for Bitcoin, Litecoin, Tron, XRP, and TON, and Solana's raw-transaction signing mode, were written against the general Trust Wallet Core API shape but haven't been compiled or run against this module's pinned `wallet-core` version. Confirm field names and produce byte-for-byte signed-output parity against the previous JS-based signers (or a testnet broadcast) before trusting any of these chains with real funds. Ethereum/BNB/Polygon signing is a straightforward reshape of what this module already proved out.
62
77
 
63
78
  ## License
64
79
 
@@ -7,12 +7,11 @@ Pod::Spec.new do |s|
7
7
  s.version = package['version']
8
8
  s.summary = package['description']
9
9
  s.description = s.summary
10
- # TODO: point at the real public repo URL before publishing to npm.
11
- s.homepage = 'https://github.com/chainberry/trust-wallet-core'
10
+ s.homepage = 'https://github.com/Chainberry-com/trust-wallet-core'
12
11
  s.license = package['license']
13
12
  s.author = 'Chainberry'
14
13
  s.platform = :ios, '16.0'
15
- s.source = { git: 'https://github.com/chainberry/trust-wallet-core.git', tag: "v#{package['version']}" }
14
+ s.source = { git: 'git@github.com:Chainberry-com/trust-wallet-core.git', tag: "v#{package['version']}" }
16
15
  s.static_framework = true
17
16
 
18
17
  s.source_files = 'ios/**/*.{h,m,mm,swift}'
@@ -64,4 +64,6 @@ dependencies {
64
64
  // Check github.com/trustwallet/wallet-core/releases for the latest version
65
65
  implementation 'com.trustwallet:wallet-core:4.1.19'
66
66
  implementation 'com.google.protobuf:protobuf-javalite:3.21.9'
67
+ // BiometricPrompt (biometry-or-device-credential gating on the Keystore-backed wallet cipher)
68
+ implementation 'androidx.biometric:biometric:1.1.0'
67
69
  }
@@ -0,0 +1,299 @@
1
+ package expo.modules.trustwalletcore
2
+
3
+ import com.google.protobuf.ByteString
4
+ import wallet.core.java.AnySigner
5
+ import wallet.core.jni.AnyAddress
6
+ import wallet.core.jni.CoinType
7
+ import wallet.core.jni.HDWallet
8
+ import wallet.core.jni.proto.Bitcoin
9
+ import wallet.core.jni.proto.Common
10
+ import wallet.core.jni.proto.Ethereum
11
+ import wallet.core.jni.proto.Ripple
12
+ import wallet.core.jni.proto.Solana
13
+ import wallet.core.jni.proto.TheOpenNetwork
14
+ import wallet.core.jni.proto.Tron
15
+ import java.math.BigInteger
16
+
17
+ class ChainSigningException(message: String) : Exception(message)
18
+
19
+ // All chains this module derives addresses for / signs transactions for.
20
+ enum class ChainKey(val coinType: CoinType) {
21
+ ETHEREUM(CoinType.ETHEREUM),
22
+ BNB(CoinType.SMARTCHAIN),
23
+ // Polygon shares Ethereum's secp256k1 key/address (same BIP44 path) — no distinct CoinType.
24
+ POLYGON(CoinType.ETHEREUM),
25
+ SOLANA(CoinType.SOLANA),
26
+ TRON(CoinType.TRON),
27
+ TON(CoinType.TON),
28
+ BITCOIN(CoinType.BITCOIN),
29
+ BITCOINCASH(CoinType.BITCOINCASH),
30
+ LITECOIN(CoinType.LITECOIN),
31
+ XRP(CoinType.XRP);
32
+
33
+ companion object {
34
+ fun fromJs(raw: String): ChainKey =
35
+ entries.find { it.name.equals(raw, ignoreCase = true) }
36
+ ?: throw ChainSigningException("Unsupported chain: $raw")
37
+ }
38
+ }
39
+
40
+ data class ChainSignResult(val signedTx: String, val meta: Map<String, Any>?)
41
+
42
+ object ChainSigner {
43
+ fun sign(chain: ChainKey, wallet: HDWallet, unsignedTx: Map<String, Any>): ChainSignResult = when (chain) {
44
+ ChainKey.ETHEREUM, ChainKey.BNB, ChainKey.POLYGON ->
45
+ ChainSignResult(signEvm(wallet, chain.coinType, unsignedTx), null)
46
+ ChainKey.SOLANA -> ChainSignResult(signSolana(wallet, unsignedTx), null)
47
+ ChainKey.BITCOIN, ChainKey.LITECOIN -> ChainSignResult(signUtxo(wallet, chain.coinType, unsignedTx), null)
48
+ ChainKey.TRON -> ChainSignResult(signTron(wallet, unsignedTx), null)
49
+ ChainKey.XRP -> ChainSignResult(signXrp(wallet, unsignedTx), null)
50
+ ChainKey.TON -> signTon(wallet, unsignedTx)
51
+ // Sending is intentionally unsupported: chainberry-wallet's prepareSelfCustodyUnsignedTx has
52
+ // no BCH case — this app only ever needs BCH address derivation, never a signed BCH tx.
53
+ ChainKey.BITCOINCASH -> throw ChainSigningException("BCH sending is not supported")
54
+ }
55
+
56
+ // MARK: - EVM (ethereum / bnb / polygon)
57
+ // unsignedTx: { to, chainId, nonce, gasLimitHex, valueHex?, dataHex?,
58
+ // gasPriceHex? (legacy) | maxFeePerGasHex + maxPriorityFeePerGasHex (EIP-1559) }
59
+
60
+ private fun signEvm(wallet: HDWallet, coin: CoinType, unsignedTx: Map<String, Any>): String {
61
+ val privateKey = wallet.getKeyForCoin(coin)
62
+ val to = unsignedTx["to"] as? String ?: throw ChainSigningException("Missing to")
63
+ val nonce = (unsignedTx["nonce"] as? Number)?.toLong() ?: throw ChainSigningException("Missing nonce")
64
+ val gasLimHex = unsignedTx["gasLimitHex"] as? String ?: throw ChainSigningException("Missing gasLimitHex")
65
+ val chainId = (unsignedTx["chainId"] as? Number)?.toLong() ?: throw ChainSigningException("Missing chainId")
66
+ val valueHex = (unsignedTx["valueHex"] as? String)?.ifEmpty { "0" } ?: "0"
67
+ val dataHex = unsignedTx["dataHex"] as? String ?: ""
68
+
69
+ val input = Ethereum.SigningInput.newBuilder().apply {
70
+ this.chainId = BigInteger.valueOf(chainId).toMinimalByteString()
71
+ this.nonce = BigInteger.valueOf(nonce).toMinimalByteString()
72
+ this.gasLimit = BigInteger(gasLimHex, 16).toMinimalByteString()
73
+ this.toAddress = to
74
+ this.privateKey = ByteString.copyFrom(privateKey.data())
75
+
76
+ this.transaction = Ethereum.Transaction.newBuilder().apply {
77
+ this.transfer = Ethereum.Transaction.Transfer.newBuilder().apply {
78
+ this.amount = BigInteger(valueHex, 16).toMinimalByteString()
79
+ if (dataHex.isNotEmpty()) this.data = ByteString.copyFrom(dataHex.hexToBytes())
80
+ }.build()
81
+ }.build()
82
+
83
+ val gasPriceHex = unsignedTx["gasPriceHex"] as? String
84
+ if (gasPriceHex != null) {
85
+ this.gasPrice = BigInteger(gasPriceHex, 16).toMinimalByteString()
86
+ } else {
87
+ val mfHex = unsignedTx["maxFeePerGasHex"] as? String
88
+ val pfHex = unsignedTx["maxPriorityFeePerGasHex"] as? String
89
+ if (mfHex != null && pfHex != null) {
90
+ this.maxFeePerGas = BigInteger(mfHex, 16).toMinimalByteString()
91
+ this.maxInclusionFeePerGas = BigInteger(pfHex, 16).toMinimalByteString()
92
+ }
93
+ }
94
+ }.build()
95
+
96
+ val output = AnySigner.sign(input, coin, Ethereum.SigningOutput.parser())
97
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
98
+ return "0x" + output.encoded.toByteArray().toHex()
99
+ }
100
+
101
+ // MARK: - Solana
102
+ // NOTE(verify-on-device): prepareSelfCustodyUnsignedTx's SOL branch returns a fully-built,
103
+ // base64-serialized unsigned @solana/web3.js Transaction (native transfer or SPL-token
104
+ // transfer) — not a simple {to,lamports,recentBlockhash} triple. wallet-core's
105
+ // Solana.SigningInput has a raw-message signing mode intended for exactly this case; confirm
106
+ // the exact field/message name against the installed wallet-core version.
107
+ // unsignedTx: { unsignedTxBase64: string }
108
+ private fun signSolana(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
109
+ val privateKey = wallet.getKeyForCoin(CoinType.SOLANA)
110
+ val unsignedTxBase64 = unsignedTx["unsignedTxBase64"] as? String
111
+ ?: throw ChainSigningException("Missing unsignedTxBase64")
112
+ val txBytes = android.util.Base64.decode(unsignedTxBase64, android.util.Base64.DEFAULT)
113
+
114
+ val input = Solana.SigningInput.newBuilder().apply {
115
+ this.privateKey = ByteString.copyFrom(privateKey.data())
116
+ this.rawMessage = Solana.RawMessage.newBuilder().apply {
117
+ this.type = Solana.MessageType.legacy
118
+ this.encoded = ByteString.copyFrom(txBytes)
119
+ }.build()
120
+ }.build()
121
+
122
+ val output = AnySigner.sign(input, CoinType.SOLANA, Solana.SigningOutput.parser())
123
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
124
+ return output.encoded
125
+ }
126
+
127
+ // MARK: - BTC / LTC (UTXO-based)
128
+ // unsignedTx (from wallet-broadcast's additive prepareBtcUtxoSet/prepareLtcUtxoSet):
129
+ // { toAddress, changeAddress, sendAmountSats, changeAmountSats, satsPerByte,
130
+ // inputs: [{ txIdHex, vout, amountSats, scriptPubKeyHex }] }
131
+ @Suppress("UNCHECKED_CAST")
132
+ private fun signUtxo(wallet: HDWallet, coin: CoinType, unsignedTx: Map<String, Any>): String {
133
+ val privateKey = wallet.getKeyForCoin(coin)
134
+ val toAddress = unsignedTx["toAddress"] as? String ?: throw ChainSigningException("Missing toAddress")
135
+ val changeAddress = unsignedTx["changeAddress"] as? String ?: throw ChainSigningException("Missing changeAddress")
136
+ val sendAmountSats = (unsignedTx["sendAmountSats"] as? String)?.toLong() ?: throw ChainSigningException("Missing sendAmountSats")
137
+ val satsPerByte = (unsignedTx["satsPerByte"] as? Number)?.toLong() ?: throw ChainSigningException("Missing satsPerByte")
138
+ val inputs = unsignedTx["inputs"] as? List<Map<String, Any>> ?: throw ChainSigningException("Missing inputs")
139
+
140
+ val utxos = inputs.map { entry ->
141
+ val txIdHex = entry["txIdHex"] as? String ?: throw ChainSigningException("Invalid UTXO entry")
142
+ val vout = (entry["vout"] as? Number)?.toInt() ?: throw ChainSigningException("Invalid UTXO entry")
143
+ val amount = (entry["amountSats"] as? String)?.toLong() ?: throw ChainSigningException("Invalid UTXO entry")
144
+ val scriptHex = entry["scriptPubKeyHex"] as? String ?: throw ChainSigningException("Invalid UTXO entry")
145
+
146
+ // On-chain/explorer txid hex is displayed big-endian; wallet-core's OutPoint.hash wants
147
+ // the reversed (little-endian, internal wire-format) byte order.
148
+ val txIdBytes = txIdHex.hexToBytes().reversedArray()
149
+
150
+ Bitcoin.UnspentTransaction.newBuilder().apply {
151
+ this.outPoint = Bitcoin.OutPoint.newBuilder().apply {
152
+ this.hash = ByteString.copyFrom(txIdBytes)
153
+ this.index = vout
154
+ }.build()
155
+ this.amount = amount
156
+ this.script = ByteString.copyFrom(scriptHex.hexToBytes())
157
+ }.build()
158
+ }
159
+
160
+ val input = Bitcoin.SigningInput.newBuilder().apply {
161
+ this.hashType = 1 // SIGHASH_ALL — stable Bitcoin protocol constant, not a wallet-core-specific value
162
+ this.amount = sendAmountSats
163
+ this.byteFee = satsPerByte
164
+ this.toAddress = toAddress
165
+ this.changeAddress = changeAddress
166
+ this.useMaxAmount = false
167
+ this.coinType = coin.value()
168
+ this.addPrivateKey(ByteString.copyFrom(privateKey.data()))
169
+ this.addAllUtxo(utxos)
170
+ }.build()
171
+
172
+ val output = AnySigner.sign(input, coin, Bitcoin.SigningOutput.parser())
173
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
174
+ return output.encoded.toByteArray().toHex()
175
+ }
176
+
177
+ // MARK: - TRX
178
+ // unsignedTx: the raw TronGrid-shaped unsigned tx object from prepareSelfCustodyUnsignedTx's
179
+ // TRX branch (raw_data.contract[], ref_block_bytes/hash, expiration, timestamp).
180
+ // NOTE(verify-on-device): covers the plain TRX-transfer contract only (matches
181
+ // prepareTrxTransaction's non-token branch); the TRC20 branch needs its own mapping.
182
+ @Suppress("UNCHECKED_CAST")
183
+ private fun signTron(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
184
+ val privateKey = wallet.getKeyForCoin(CoinType.TRON)
185
+ val rawData = unsignedTx["raw_data"] as? Map<String, Any> ?: throw ChainSigningException("Missing raw_data")
186
+ val contracts = rawData["contract"] as? List<Map<String, Any>> ?: throw ChainSigningException("Missing contract")
187
+ val parameter = contracts.firstOrNull()?.get("parameter") as? Map<String, Any> ?: throw ChainSigningException("Missing contract parameter")
188
+ val value = parameter["value"] as? Map<String, Any> ?: throw ChainSigningException("Missing contract value")
189
+ val toAddressHex = value["to_address"] as? String ?: throw ChainSigningException("Missing to_address")
190
+ val amount = (value["amount"] as? Number)?.toLong() ?: throw ChainSigningException("Missing amount")
191
+ val refBlockBytes = rawData["ref_block_bytes"] as? String ?: throw ChainSigningException("Missing ref_block_bytes")
192
+ val refBlockHash = rawData["ref_block_hash"] as? String ?: throw ChainSigningException("Missing ref_block_hash")
193
+ val expiration = (rawData["expiration"] as? Number)?.toLong() ?: throw ChainSigningException("Missing expiration")
194
+ val timestamp = (rawData["timestamp"] as? Number)?.toLong() ?: throw ChainSigningException("Missing timestamp")
195
+
196
+ // TronGrid's raw hex address (0x41-prefixed 21 bytes) -> base58check "T..." string that
197
+ // wallet-core's TransferContract.to_address expects.
198
+ val toAddress = AnyAddress(toAddressHex.hexToBytes(), CoinType.TRON).description()
199
+
200
+ val input = Tron.SigningInput.newBuilder().apply {
201
+ this.privateKey = ByteString.copyFrom(privateKey.data())
202
+ this.transaction = Tron.Transaction.newBuilder().apply {
203
+ this.transfer = Tron.TransferContract.newBuilder().apply {
204
+ this.toAddress = toAddress
205
+ this.amount = amount
206
+ }.build()
207
+ }.build()
208
+ this.refBlockBytes = ByteString.copyFrom(refBlockBytes.hexToBytes())
209
+ this.refBlockHash = ByteString.copyFrom(refBlockHash.hexToBytes())
210
+ this.expiration = expiration
211
+ this.timestamp = timestamp
212
+ }.build()
213
+
214
+ val output = AnySigner.sign(input, CoinType.TRON, Tron.SigningOutput.parser())
215
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
216
+ return output.json
217
+ }
218
+
219
+ // MARK: - XRP
220
+ // unsignedTx: xrpl.js `Payment` object (Account, Destination, Amount (drops, string),
221
+ // Fee (drops, string), Sequence, LastLedgerSequence, DestinationTag?).
222
+ private fun signXrp(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
223
+ val privateKey = wallet.getKeyForCoin(CoinType.XRP)
224
+ val account = unsignedTx["Account"] as? String ?: throw ChainSigningException("Missing Account")
225
+ val destination = unsignedTx["Destination"] as? String ?: throw ChainSigningException("Missing Destination")
226
+ val amountDrops = unsignedTx["Amount"] as? String ?: throw ChainSigningException("Missing Amount")
227
+ val feeDrops = unsignedTx["Fee"] as? String ?: throw ChainSigningException("Missing Fee")
228
+ val sequence = (unsignedTx["Sequence"] as? Number)?.toInt() ?: throw ChainSigningException("Missing Sequence")
229
+ val lastLedgerSequence = (unsignedTx["LastLedgerSequence"] as? Number)?.toInt()
230
+ val destinationTag = (unsignedTx["DestinationTag"] as? Number)?.toInt()
231
+
232
+ val input = Ripple.SigningInput.newBuilder().apply {
233
+ this.privateKey = ByteString.copyFrom(privateKey.data())
234
+ this.account = account
235
+ this.fee = feeDrops.toLong()
236
+ this.sequence = sequence
237
+ lastLedgerSequence?.let { this.lastLedgerSequence = it }
238
+ this.opPayment = Ripple.OperationPayment.newBuilder().apply {
239
+ this.amount = amountDrops.toLong()
240
+ this.destination = destination
241
+ destinationTag?.let { this.destinationTag = it }
242
+ }.build()
243
+ }.build()
244
+
245
+ val output = AnySigner.sign(input, CoinType.XRP, Ripple.SigningOutput.parser())
246
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
247
+ return output.encoded.toByteArray().toHex()
248
+ }
249
+
250
+ // MARK: - TON
251
+ // unsignedTx: { toAddress, amount, seqno, memoId? }.
252
+ // NOTE(verify-on-device): confirm `amount` units (nanoton vs TON) and that wallet_version
253
+ // V4R2 matches the address format the current @ton/* implementation derives.
254
+ private fun signTon(wallet: HDWallet, unsignedTx: Map<String, Any>): ChainSignResult {
255
+ val privateKey = wallet.getKeyForCoin(CoinType.TON)
256
+ val toAddress = unsignedTx["toAddress"] as? String ?: throw ChainSigningException("Missing toAddress")
257
+ val amount = (unsignedTx["amount"] as? String)?.toULongOrNull() ?: throw ChainSigningException("Missing amount")
258
+ val seqno = (unsignedTx["seqno"] as? Number)?.toInt() ?: throw ChainSigningException("Missing seqno")
259
+ val memoId = unsignedTx["memoId"] as? String
260
+
261
+ val transfer = TheOpenNetwork.Transfer.newBuilder().apply {
262
+ this.dest = toAddress
263
+ this.amount = amount.toLong()
264
+ this.mode = TheOpenNetwork.SendMode.PAY_GAS_SEPARATELY_VALUE or TheOpenNetwork.SendMode.IGNORE_ACTION_PHASE_ERRORS_VALUE
265
+ this.bounceable = true
266
+ memoId?.let { this.comment = it }
267
+ }.build()
268
+
269
+ val input = TheOpenNetwork.SigningInput.newBuilder().apply {
270
+ this.privateKey = ByteString.copyFrom(privateKey.data())
271
+ this.walletVersion = TheOpenNetwork.WalletVersion.WALLET_V4_R2
272
+ this.sequenceNumber = seqno
273
+ this.transfer = transfer
274
+ }.build()
275
+
276
+ val output = AnySigner.sign(input, CoinType.TON, TheOpenNetwork.SigningOutput.parser())
277
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
278
+ return ChainSignResult(output.encoded, mapOf("txHash" to output.hash.toByteArray().toHex()))
279
+ }
280
+ }
281
+
282
+ // Helpers
283
+
284
+ private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
285
+
286
+ private fun String.hexToBytes(): ByteArray {
287
+ val s = removePrefix("0x").let { if (it.length % 2 == 0) it else "0$it" }
288
+ return ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
289
+ }
290
+
291
+ // BigInteger -> minimal big-endian ByteString (strips Java's leading sign byte)
292
+ private fun BigInteger.toMinimalByteString(): ByteString {
293
+ val raw = toByteArray()
294
+ return if (raw.size > 1 && raw[0] == 0.toByte()) {
295
+ ByteString.copyFrom(raw, 1, raw.size - 1)
296
+ } else {
297
+ ByteString.copyFrom(raw)
298
+ }
299
+ }
@@ -0,0 +1,182 @@
1
+ package expo.modules.trustwalletcore
2
+
3
+ import android.content.Context
4
+ import android.os.Build
5
+ import android.security.keystore.KeyGenParameterSpec
6
+ import android.security.keystore.KeyProperties
7
+ import androidx.biometric.BiometricManager
8
+ import androidx.biometric.BiometricPrompt
9
+ import androidx.fragment.app.FragmentActivity
10
+ import org.json.JSONObject
11
+ import java.io.File
12
+ import java.security.KeyStore
13
+ import javax.crypto.Cipher
14
+ import javax.crypto.KeyGenerator
15
+ import javax.crypto.SecretKey
16
+ import javax.crypto.spec.GCMParameterSpec
17
+ import kotlin.coroutines.resume
18
+ import kotlin.coroutines.resumeWithException
19
+ import kotlin.coroutines.suspendCoroutine
20
+
21
+ /**
22
+ * Persists mnemonics as files encrypted with a hardware-backed, biometry-or-device-credential
23
+ * gated Android Keystore AES key (one key per wallet), plus a parallel ungated metadata store
24
+ * (walletId -> per-chain addresses) for read-only UI. Deliberately not `EncryptedSharedPreferences`
25
+ * or wallet-core's `StoredKey` keystore-JSON — confidentiality comes entirely from the Keystore
26
+ * key never leaving the TEE/StrongBox, not from the on-disk file encoding.
27
+ */
28
+ class NativeWalletStoreError(message: String) : Exception(message)
29
+
30
+ object NativeWalletStore {
31
+ private const val KEY_ALIAS_PREFIX = "vault_wallet_"
32
+ private const val ANDROID_KEYSTORE = "AndroidKeyStore"
33
+ private const val TRANSFORMATION = "AES/GCM/NoPadding"
34
+ private const val GCM_IV_LENGTH = 12
35
+ private const val GCM_TAG_LENGTH_BITS = 128
36
+ private const val WALLETS_DIR = "vault_wallets"
37
+ private const val METADATA_FILE = "metadata.json"
38
+
39
+ private fun walletsDir(context: Context): File =
40
+ File(context.filesDir, WALLETS_DIR).apply { mkdirs() }
41
+
42
+ private fun mnemonicFile(context: Context, walletId: String): File =
43
+ File(walletsDir(context), "$walletId.enc")
44
+
45
+ private fun metadataFile(context: Context): File =
46
+ File(walletsDir(context), METADATA_FILE)
47
+
48
+ // MARK: - Keystore key management
49
+
50
+ private fun keyStore(): KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
51
+
52
+ private fun getOrCreateKey(walletId: String): SecretKey {
53
+ val alias = KEY_ALIAS_PREFIX + walletId
54
+ val ks = keyStore()
55
+ (ks.getKey(alias, null) as? SecretKey)?.let { return it }
56
+
57
+ val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
58
+ val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
59
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
60
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
61
+ .setUserAuthenticationRequired(true)
62
+
63
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
64
+ builder.setUserAuthenticationParameters(
65
+ 0,
66
+ KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
67
+ )
68
+ } else {
69
+ @Suppress("DEPRECATION")
70
+ builder.setUserAuthenticationValidityDurationSeconds(-1)
71
+ }
72
+
73
+ keyGenerator.init(builder.build())
74
+ return keyGenerator.generateKey()
75
+ }
76
+
77
+ // MARK: - Mnemonic (biometry/device-credential gated)
78
+
79
+ /** Encrypts and writes the mnemonic. Also requires user authentication (the key itself is
80
+ * auth-gated for every use, encrypt included) — callers should invoke this right after a
81
+ * successful [authenticate] prompt, same as [loadMnemonic]. */
82
+ fun saveMnemonic(context: Context, walletId: String, mnemonic: String, authenticatedCipher: Cipher) {
83
+ val iv = authenticatedCipher.iv
84
+ val ciphertext = authenticatedCipher.doFinal(mnemonic.toByteArray(Charsets.UTF_8))
85
+ mnemonicFile(context, walletId).writeBytes(iv + ciphertext)
86
+ }
87
+
88
+ fun loadMnemonic(context: Context, walletId: String, authenticatedCipher: Cipher): String {
89
+ val file = mnemonicFile(context, walletId)
90
+ if (!file.exists()) throw NativeWalletStoreError("Wallet not found: $walletId")
91
+ val bytes = file.readBytes()
92
+ val ciphertext = bytes.copyOfRange(GCM_IV_LENGTH, bytes.size)
93
+ return String(authenticatedCipher.doFinal(ciphertext), Charsets.UTF_8)
94
+ }
95
+
96
+ fun deleteMnemonic(context: Context, walletId: String) {
97
+ mnemonicFile(context, walletId).delete()
98
+ runCatching { keyStore().deleteEntry(KEY_ALIAS_PREFIX + walletId) }
99
+ }
100
+
101
+ /** A `Cipher` initialized for encryption, to be unlocked via [authenticate] before use. */
102
+ fun encryptCipher(walletId: String): Cipher {
103
+ val cipher = Cipher.getInstance(TRANSFORMATION)
104
+ cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey(walletId))
105
+ return cipher
106
+ }
107
+
108
+ /** A `Cipher` initialized for decryption using the IV already on disk, to be unlocked via
109
+ * [authenticate] before use. */
110
+ fun decryptCipher(context: Context, walletId: String): Cipher {
111
+ val file = mnemonicFile(context, walletId)
112
+ if (!file.exists()) throw NativeWalletStoreError("Wallet not found: $walletId")
113
+ val iv = file.readBytes().copyOfRange(0, GCM_IV_LENGTH)
114
+ val cipher = Cipher.getInstance(TRANSFORMATION)
115
+ cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(walletId), GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv))
116
+ return cipher
117
+ }
118
+
119
+ // MARK: - Metadata (ungated: walletId -> { chain: address })
120
+
121
+ fun saveMetadata(context: Context, wallets: Map<String, Map<String, String>>) {
122
+ val root = JSONObject()
123
+ for ((walletId, addresses) in wallets) {
124
+ root.put(walletId, JSONObject(addresses as Map<*, *>))
125
+ }
126
+ metadataFile(context).writeText(root.toString())
127
+ }
128
+
129
+ fun loadMetadata(context: Context): Map<String, Map<String, String>> {
130
+ val file = metadataFile(context)
131
+ if (!file.exists()) return emptyMap()
132
+ val root = JSONObject(file.readText())
133
+ val result = mutableMapOf<String, Map<String, String>>()
134
+ for (walletId in root.keys()) {
135
+ val addressesJson = root.getJSONObject(walletId)
136
+ val addresses = mutableMapOf<String, String>()
137
+ for (chain in addressesJson.keys()) {
138
+ addresses[chain] = addressesJson.getString(chain)
139
+ }
140
+ result[walletId] = addresses
141
+ }
142
+ return result
143
+ }
144
+
145
+ // MARK: - Biometric/device-credential prompt
146
+
147
+ /** Authenticates the given [Cipher] via BiometricPrompt (biometry-or-device-credential),
148
+ * returning the same cipher ready for [Cipher.doFinal]. */
149
+ suspend fun authenticate(activity: FragmentActivity, cipher: Cipher, title: String): Cipher =
150
+ suspendCoroutine { continuation ->
151
+ val allowedAuthenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG or
152
+ BiometricManager.Authenticators.DEVICE_CREDENTIAL
153
+
154
+ val promptInfo = BiometricPrompt.PromptInfo.Builder()
155
+ .setTitle(title)
156
+ .setAllowedAuthenticators(allowedAuthenticators)
157
+ .build()
158
+
159
+ val executor = androidx.core.content.ContextCompat.getMainExecutor(activity)
160
+ val prompt = BiometricPrompt(
161
+ activity,
162
+ executor,
163
+ object : BiometricPrompt.AuthenticationCallback() {
164
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
165
+ val authenticatedCipher = result.cryptoObject?.cipher
166
+ ?: return continuation.resumeWithException(NativeWalletStoreError("No authenticated cipher returned"))
167
+ continuation.resume(authenticatedCipher)
168
+ }
169
+
170
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
171
+ continuation.resumeWithException(NativeWalletStoreError("Authentication error: $errString"))
172
+ }
173
+
174
+ override fun onAuthenticationFailed() {
175
+ // Not terminal — BiometricPrompt keeps the sheet open for retry; only
176
+ // onAuthenticationError/onAuthenticationSucceeded resolve the continuation.
177
+ }
178
+ }
179
+ )
180
+ prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
181
+ }
182
+ }
@@ -1,17 +1,15 @@
1
1
  package expo.modules.trustwalletcore
2
2
 
3
- import com.google.protobuf.ByteString
3
+ import androidx.fragment.app.FragmentActivity
4
+ import expo.modules.kotlin.exception.CodedException
4
5
  import expo.modules.kotlin.modules.Module
5
6
  import expo.modules.kotlin.modules.ModuleDefinition
6
- import wallet.core.java.AnySigner
7
- import wallet.core.jni.CoinType
8
7
  import wallet.core.jni.HDWallet
9
- import wallet.core.jni.PrivateKey
10
- import wallet.core.jni.proto.Common
11
- import wallet.core.jni.proto.Ethereum
12
- import wallet.core.jni.proto.Solana
13
- import java.math.BigInteger
8
+ import java.util.UUID
14
9
 
10
+ // Mnemonic/private-key material never crosses back to JS except `exportMnemonic` — an
11
+ // explicit, biometric/device-credential-gated backup flow. Every other method returns only
12
+ // walletIds, addresses, or signed transaction bytes/hex.
15
13
  class TrustWalletCoreModule : Module() {
16
14
  companion object {
17
15
  init {
@@ -20,124 +18,74 @@ class TrustWalletCoreModule : Module() {
20
18
  }
21
19
  }
22
20
 
21
+ private val context get() = appContext.reactContext
22
+ ?: throw CodedException("NoContext", "React context unavailable", null)
23
+
24
+ private val activity: FragmentActivity
25
+ get() = appContext.currentActivity as? FragmentActivity
26
+ ?: throw CodedException("NoActivity", "No foreground FragmentActivity to host the biometric prompt", null)
27
+
23
28
  override fun definition() = ModuleDefinition {
24
29
  Name("TrustWalletCore")
25
30
 
26
- // Returns { mnemonic } — strength 128 = 12 words, 256 = 24 words
27
- AsyncFunction("generateWallet") { strength: Int, passphrase: String ->
28
- mapOf("mnemonic" to HDWallet(strength, passphrase).mnemonic())
29
- }
30
-
31
- // Validates mnemonic and returns { mnemonic }
32
- AsyncFunction("restoreWallet") { mnemonic: String, passphrase: String ->
33
- HDWallet(mnemonic, passphrase) // throws on invalid mnemonic
34
- mapOf("mnemonic" to mnemonic)
31
+ // strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses }.
32
+ AsyncFunction("createWallet") { strength: Int, passphrase: String ->
33
+ val wallet = HDWallet(strength, passphrase)
34
+ persistNewWallet(wallet)
35
35
  }
36
36
 
37
- // Derives address and private key for a named coin
38
- // coin: "ethereum" | "solana"
39
- AsyncFunction("getAddressForCoin") { mnemonic: String, coin: String, passphrase: String ->
40
- val wallet = HDWallet(mnemonic, passphrase)
41
- val coinType = resolveCoinType(coin)
42
- mapOf(
43
- "address" to wallet.getAddressForCoin(coinType),
44
- "privateKey" to wallet.getKeyForCoin(coinType).data().toHex(),
45
- )
37
+ // One-time mnemonic exposure from JS, at import only — never retained after this call.
38
+ // Returns { walletId, addresses }.
39
+ AsyncFunction("importWallet") { mnemonic: String, passphrase: String ->
40
+ val wallet = HDWallet(mnemonic, passphrase) // throws on invalid mnemonic
41
+ persistNewWallet(wallet)
46
42
  }
47
43
 
48
- // Signs an Ethereum transaction; returns 0x-prefixed RLP-encoded signed tx hex
49
- // txParams: { to, valueHex, nonce, gasLimitHex, chainId, dataHex?,
50
- // gasPriceHex? (legacy) | maxFeePerGasHex + maxPriorityFeePerGasHex (EIP-1559) }
51
- AsyncFunction("signEthereumTransaction") { privateKeyHex: String, txParams: Map<String, Any> ->
52
- val privateKey = PrivateKey(privateKeyHex.hexToBytes())
53
- val to = txParams["to"] as String
54
- val valueHex = (txParams["valueHex"] as? String)?.ifEmpty { "0" } ?: "0"
55
- val nonce = (txParams["nonce"] as Number).toInt()
56
- val gasLimHex = txParams["gasLimitHex"] as String
57
- val chainId = (txParams["chainId"] as Number).toInt()
58
- val dataHex = (txParams["dataHex"] as? String) ?: ""
59
-
60
- val input = Ethereum.SigningInput.newBuilder().apply {
61
- this.chainId = BigInteger.valueOf(chainId.toLong()).toMinimalByteString()
62
- this.nonce = BigInteger.valueOf(nonce.toLong()).toMinimalByteString()
63
- this.gasLimit = BigInteger(gasLimHex, 16).toMinimalByteString()
64
- this.toAddress = to
65
- this.privateKey = ByteString.copyFrom(privateKey.data())
66
-
67
- this.transaction = Ethereum.Transaction.newBuilder().apply {
68
- this.transfer = Ethereum.Transaction.Transfer.newBuilder().apply {
69
- this.amount = BigInteger(valueHex, 16).toMinimalByteString()
70
- if (dataHex.isNotEmpty()) this.data = ByteString.copyFrom(dataHex.hexToBytes())
71
- }.build()
72
- }.build()
73
-
74
- val gasPriceHex = txParams["gasPriceHex"] as? String
75
- if (gasPriceHex != null) {
76
- this.gasPrice = BigInteger(gasPriceHex, 16).toMinimalByteString()
77
- } else {
78
- val mfHex = txParams["maxFeePerGasHex"] as? String
79
- val pfHex = txParams["maxPriorityFeePerGasHex"] as? String
80
- if (mfHex != null && pfHex != null) {
81
- this.maxFeePerGas = BigInteger(mfHex, 16).toMinimalByteString()
82
- this.maxInclusionFeePerGas = BigInteger(pfHex, 16).toMinimalByteString()
83
- }
84
- }
85
- }.build()
86
-
87
- val output = AnySigner.sign(input, CoinType.ETHEREUM, Ethereum.SigningOutput.parser())
88
- if (output.error != Common.SigningError.OK) {
89
- throw Exception("Signing failed: ${output.errorMessage}")
44
+ // Reads only the ungated metadata store no biometric prompt.
45
+ AsyncFunction("listWallets") {
46
+ NativeWalletStore.loadMetadata(context).map { (walletId, addresses) ->
47
+ mapOf("walletId" to walletId, "addresses" to addresses)
90
48
  }
91
- "0x" + output.encoded.toByteArray().toHex()
92
49
  }
93
50
 
94
- // Signs a Solana transfer; returns base64-encoded signed transaction
95
- // txParams: { to, lamports (string, lamports amount), recentBlockhash }
96
- AsyncFunction("signSolanaTransaction") { privateKeyHex: String, txParams: Map<String, Any> ->
97
- val privateKey = PrivateKey(privateKeyHex.hexToBytes())
98
- val to = txParams["to"] as String
99
- val lamports = (txParams["lamports"] as String).toLong()
100
- val recentBlockhash = txParams["recentBlockhash"] as String
51
+ AsyncFunction("deleteWallet") { walletId: String ->
52
+ NativeWalletStore.deleteMnemonic(context, walletId)
53
+ val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
54
+ metadata.remove(walletId)
55
+ NativeWalletStore.saveMetadata(context, metadata)
56
+ }
101
57
 
102
- val input = Solana.SigningInput.newBuilder().apply {
103
- this.recentBlockhash = recentBlockhash
104
- this.privateKey = ByteString.copyFrom(privateKey.data())
105
- this.transferTransaction = Solana.Transfer.newBuilder().apply {
106
- this.recipient = to
107
- this.value = lamports
108
- }.build()
109
- }.build()
58
+ // Triggers the native biometry/device-credential prompt, then signs entirely in-process.
59
+ // Returns { signedTx, meta? }.
60
+ AsyncFunction("signTransaction") { walletId: String, chain: String, unsignedTx: Map<String, Any> ->
61
+ val chainKey = ChainKey.fromJs(chain)
62
+ val cipher = NativeWalletStore.authenticate(activity, NativeWalletStore.decryptCipher(context, walletId), "Sign transaction")
63
+ val mnemonic = NativeWalletStore.loadMnemonic(context, walletId, cipher)
64
+ val wallet = HDWallet(mnemonic, "")
65
+ val result = ChainSigner.sign(chainKey, wallet, unsignedTx)
66
+ val response = mutableMapOf<String, Any>("signedTx" to result.signedTx)
67
+ result.meta?.let { response["meta"] = it }
68
+ response
69
+ }
110
70
 
111
- val output = AnySigner.sign(input, CoinType.SOLANA, Solana.SigningOutput.parser())
112
- if (output.error != Common.SigningError.OK) {
113
- throw Exception("Signing failed: ${output.errorMessage}")
114
- }
115
- output.encoded
71
+ // The one sanctioned mnemonic exposure — explicit backup flow only.
72
+ AsyncFunction("exportMnemonic") { walletId: String ->
73
+ val cipher = NativeWalletStore.authenticate(activity, NativeWalletStore.decryptCipher(context, walletId), "Reveal recovery phrase")
74
+ NativeWalletStore.loadMnemonic(context, walletId, cipher)
116
75
  }
117
76
  }
118
- }
119
77
 
120
- // Helpers
121
- private fun resolveCoinType(coin: String): CoinType = when (coin.lowercase()) {
122
- "ethereum" -> CoinType.ETHEREUM
123
- "solana" -> CoinType.SOLANA
124
- "bnb" -> CoinType.SMARTCHAIN
125
- else -> throw IllegalArgumentException("Unsupported coin: $coin")
126
- }
78
+ private suspend fun persistNewWallet(wallet: HDWallet): Map<String, Any> {
79
+ val walletId = UUID.randomUUID().toString()
80
+ val addresses = ChainKey.entries.associate { chain -> chain.name.lowercase() to wallet.getAddressForCoin(chain.coinType) }
127
81
 
128
- private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
82
+ val cipher = NativeWalletStore.authenticate(activity, NativeWalletStore.encryptCipher(walletId), "Secure your new wallet")
83
+ NativeWalletStore.saveMnemonic(context, walletId, wallet.mnemonic(), cipher)
129
84
 
130
- private fun String.hexToBytes(): ByteArray {
131
- val s = removePrefix("0x").let { if (it.length % 2 == 0) it else "0$it" }
132
- return ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
133
- }
85
+ val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
86
+ metadata[walletId] = addresses
87
+ NativeWalletStore.saveMetadata(context, metadata)
134
88
 
135
- // BigInteger minimal big-endian ByteString (strips Java's leading sign byte)
136
- private fun BigInteger.toMinimalByteString(): ByteString {
137
- val raw = toByteArray()
138
- return if (raw.size > 1 && raw[0] == 0.toByte()) {
139
- ByteString.copyFrom(raw, 1, raw.size - 1)
140
- } else {
141
- ByteString.copyFrom(raw)
89
+ return mapOf("walletId" to walletId, "addresses" to addresses)
142
90
  }
143
91
  }
@@ -1,145 +1,107 @@
1
1
  import ExpoModulesCore
2
2
  import WalletCore
3
+ import LocalAuthentication
3
4
 
4
- // All numeric transaction fields are bare hex strings (no 0x prefix) to avoid JS number precision loss.
5
+ // Mnemonic/private-key material never crosses back to JS except `exportMnemonic` — an
6
+ // explicit, biometric/passcode-gated backup flow. Every other method returns only
7
+ // walletIds, addresses, or signed transaction bytes/hex.
5
8
  public class TrustWalletCoreModule: Module {
6
9
  public func definition() -> ModuleDefinition {
7
10
  Name("TrustWalletCore")
8
11
 
9
- // Returns { mnemonic } — strength 128 = 12 words, 256 = 24 words
10
- AsyncFunction("generateWallet") { (strength: Int, passphrase: String) throws -> [String: String] in
12
+ // strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses }.
13
+ AsyncFunction("createWallet") { (strength: Int, passphrase: String) throws -> [String: Any] in
11
14
  guard let wallet = HDWallet(strength: UInt32(strength), passphrase: passphrase) else {
12
15
  throw Exception(name: "WalletError", description: "Failed to generate wallet")
13
16
  }
14
- return ["mnemonic": wallet.mnemonic]
17
+ return try Self.persistNewWallet(wallet: wallet)
15
18
  }
16
19
 
17
- // Validates mnemonic and returns { mnemonic }
18
- AsyncFunction("restoreWallet") { (mnemonic: String, passphrase: String) throws -> [String: String] in
19
- guard HDWallet(mnemonic: mnemonic, passphrase: passphrase) != nil else {
20
- throw Exception(name: "InvalidMnemonic", description: "Invalid mnemonic phrase")
21
- }
22
- return ["mnemonic": mnemonic]
23
- }
24
-
25
- // Derives address and private key for a named coin
26
- // coin: "ethereum" | "solana"
27
- AsyncFunction("getAddressForCoin") { (mnemonic: String, coin: String, passphrase: String) throws -> [String: String] in
20
+ // One-time mnemonic exposure from JS, at import only — never retained after this call.
21
+ // Returns { walletId, addresses }.
22
+ AsyncFunction("importWallet") { (mnemonic: String, passphrase: String) throws -> [String: Any] in
28
23
  guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: passphrase) else {
29
24
  throw Exception(name: "InvalidMnemonic", description: "Invalid mnemonic phrase")
30
25
  }
31
- let coinType = try Self.resolveCoinType(coin)
32
- return [
33
- "address": wallet.getAddressForCoin(coin: coinType),
34
- "privateKey": wallet.getKeyForCoin(coin: coinType).data.hexString,
35
- ]
26
+ return try Self.persistNewWallet(wallet: wallet)
36
27
  }
37
28
 
38
- // Signs an Ethereum transaction; returns 0x-prefixed RLP-encoded signed tx hex
39
- // txParams: { to, valueHex, nonce, gasLimitHex, chainId, dataHex?,
40
- // gasPriceHex? (legacy) | maxFeePerGasHex + maxPriorityFeePerGasHex (EIP-1559) }
41
- AsyncFunction("signEthereumTransaction") { (privateKeyHex: String, txParams: [String: Any]) throws -> String in
42
- guard let pkData = Data(hexString: privateKeyHex),
43
- let privateKey = PrivateKey(data: pkData) else {
44
- throw Exception(name: "InvalidKey", description: "Invalid private key hex")
45
- }
46
- guard let to = txParams["to"] as? String,
47
- let nonce = txParams["nonce"] as? Int,
48
- let gasLimHex = txParams["gasLimitHex"] as? String,
49
- let chainId = txParams["chainId"] as? Int else {
50
- throw Exception(name: "InvalidParams", description: "Missing required tx params")
51
- }
52
- let valueHex = (txParams["valueHex"] as? String) ?? "0"
53
- let dataHex = (txParams["dataHex"] as? String) ?? ""
54
-
55
- var input = EthereumSigningInput()
56
- input.chainID = Self.intToData(chainId)
57
- input.nonce = Self.intToData(nonce)
58
- input.gasLimit = Self.hexData(gasLimHex) ?? Data()
59
- input.toAddress = to
60
- input.privateKey = privateKey.data
61
- if !dataHex.isEmpty { input.txData = Self.hexData(dataHex) ?? Data() }
62
-
63
- var transfer = EthereumTransaction.Transfer()
64
- transfer.amount = Self.hexData(valueHex) ?? Data([0])
65
- var tx = EthereumTransaction()
66
- tx.transfer = transfer
67
- input.transaction = tx
68
-
69
- if let gasPriceHex = txParams["gasPriceHex"] as? String {
70
- input.gasPrice = Self.hexData(gasPriceHex) ?? Data()
71
- } else if let mfHex = txParams["maxFeePerGasHex"] as? String,
72
- let pfHex = txParams["maxPriorityFeePerGasHex"] as? String {
73
- input.maxFeePerGas = Self.hexData(mfHex) ?? Data()
74
- input.maxInclusionFeePerGas = Self.hexData(pfHex) ?? Data()
29
+ // Reads only the ungated metadata store no biometric prompt.
30
+ AsyncFunction("listWallets") { () -> [[String: Any]] in
31
+ NativeWalletStore.loadMetadata().map { walletId, addresses in
32
+ ["walletId": walletId, "addresses": addresses]
75
33
  }
34
+ }
76
35
 
77
- let output: EthereumSigningOutput = AnySigner.sign(input: input, coin: .ethereum)
78
- guard output.error == .ok else {
79
- throw Exception(name: "SigningFailed", description: output.errorMessage)
80
- }
81
- return "0x" + output.encoded.hexString
36
+ AsyncFunction("deleteWallet") { (walletId: String) throws -> Void in
37
+ NativeWalletStore.deleteMnemonic(walletId: walletId)
38
+ var metadata = NativeWalletStore.loadMetadata()
39
+ metadata.removeValue(forKey: walletId)
40
+ try NativeWalletStore.saveMetadata(metadata)
82
41
  }
83
42
 
84
- // Signs a Solana transfer; returns base64-encoded signed transaction
85
- // txParams: { to, lamports (string), recentBlockhash }
86
- AsyncFunction("signSolanaTransaction") { (privateKeyHex: String, txParams: [String: Any]) throws -> String in
87
- guard let pkData = Data(hexString: privateKeyHex),
88
- let privateKey = PrivateKey(data: pkData) else {
89
- throw Exception(name: "InvalidKey", description: "Invalid private key hex")
90
- }
91
- guard let to = txParams["to"] as? String,
92
- let lamportsStr = txParams["lamports"] as? String,
93
- let lamports = UInt64(lamportsStr),
94
- let recentBlockhash = txParams["recentBlockhash"] as? String else {
95
- throw Exception(name: "InvalidParams", description: "Missing required Solana tx params (to, lamports, recentBlockhash)")
43
+ // Triggers the native biometry/passcode prompt, then signs entirely in-process.
44
+ // Returns { signedTx, meta? }.
45
+ AsyncFunction("signTransaction") { (walletId: String, chain: String, unsignedTx: [String: Any]) throws -> [String: Any] in
46
+ let chainKey = try ChainKey(fromJs: chain)
47
+ let context = try await Self.authenticatedContext(reason: "Sign transaction")
48
+ let mnemonic = try NativeWalletStore.loadMnemonic(walletId: walletId, context: context)
49
+ guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else {
50
+ throw Exception(name: "InvalidMnemonic", description: "Stored mnemonic failed validation")
96
51
  }
52
+ let result = try ChainSigner.sign(chain: chainKey, wallet: wallet, unsignedTx: unsignedTx)
53
+ var response: [String: Any] = ["signedTx": result.signedTx]
54
+ if let meta = result.meta { response["meta"] = meta }
55
+ return response
56
+ }
97
57
 
98
- var transfer = SolanaTransfer()
99
- transfer.recipient = to
100
- transfer.value = lamports
101
-
102
- var input = SolanaSigningInput()
103
- input.recentBlockhash = recentBlockhash
104
- input.privateKey = privateKey.data
105
- input.transferTransaction = transfer
106
-
107
- let output: SolanaSigningOutput = AnySigner.sign(input: input, coin: .solana)
108
- guard output.error == .ok else {
109
- throw Exception(name: "SigningFailed", description: output.errorMessage)
110
- }
111
- return output.encoded
58
+ // The one sanctioned mnemonic exposure — explicit backup flow only.
59
+ AsyncFunction("exportMnemonic") { (walletId: String) throws -> String in
60
+ let context = try await Self.authenticatedContext(reason: "Reveal recovery phrase")
61
+ return try NativeWalletStore.loadMnemonic(walletId: walletId, context: context)
112
62
  }
113
63
  }
114
64
 
115
65
  // MARK: - Helpers
116
66
 
117
- private static func resolveCoinType(_ coin: String) throws -> CoinType {
118
- switch coin.lowercased() {
119
- case "ethereum": return .ethereum
120
- case "solana": return .solana
121
- case "bnb": return .smartChain
122
- default:
123
- throw Exception(name: "UnsupportedCoin", description: "Unsupported coin: \(coin)")
67
+ private static func persistNewWallet(wallet: HDWallet) throws -> [String: Any] {
68
+ let walletId = UUID().uuidString
69
+ var addresses: [String: String] = [:]
70
+ for chain in ChainKey.allCases {
71
+ addresses[chain.rawValue] = wallet.getAddressForCoin(coin: chain.coinType)
124
72
  }
125
- }
126
73
 
127
- // Parses a hex string (with or without 0x, odd or even length) into Data
128
- private static func hexData(_ hex: String) -> Data? {
129
- let s = hex.hasPrefix("0x") ? String(hex.dropFirst(2)) : hex
130
- let padded = s.count % 2 == 0 ? s : "0" + s
131
- return padded.isEmpty ? Data([0]) : Data(hexString: padded)
74
+ try NativeWalletStore.saveMnemonic(wallet.mnemonic, walletId: walletId)
75
+ var metadata = NativeWalletStore.loadMetadata()
76
+ metadata[walletId] = addresses
77
+ try NativeWalletStore.saveMetadata(metadata)
78
+
79
+ return ["walletId": walletId, "addresses": addresses]
132
80
  }
133
81
 
134
- // Encodes a non-negative integer as minimal big-endian Data
135
- private static func intToData(_ value: Int) -> Data {
136
- guard value > 0 else { return Data([0]) }
137
- var v = value
138
- var bytes: [UInt8] = []
139
- while v > 0 {
140
- bytes.insert(UInt8(v & 0xFF), at: 0)
141
- v >>= 8
82
+ /// Prompts biometry-or-device-passcode via `.deviceOwnerAuthentication` (Apple's
83
+ /// combined policy no separate fallback branch needed), then hands back the
84
+ /// now-authenticated context for a single Keychain read via `kSecUseAuthenticationContext`.
85
+ private static func authenticatedContext(reason: String) async throws -> LAContext {
86
+ let context = LAContext()
87
+ var evalError: NSError?
88
+ guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &evalError) else {
89
+ throw Exception(
90
+ name: "BiometryUnavailable",
91
+ description: evalError?.localizedDescription ?? "No biometry or device passcode is set up"
92
+ )
93
+ }
94
+ return try await withCheckedThrowingContinuation { continuation in
95
+ context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authError in
96
+ if success {
97
+ continuation.resume(returning: context)
98
+ } else {
99
+ continuation.resume(throwing: Exception(
100
+ name: "AuthenticationFailed",
101
+ description: authError?.localizedDescription ?? "Authentication failed"
102
+ ))
103
+ }
104
+ }
142
105
  }
143
- return Data(bytes)
144
106
  }
145
107
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chainberry/trust-wallet-core",
3
- "version": "1.0.1",
4
- "description": "Expo native module wrapping Trust Wallet Core for HD wallet generation, address derivation, and transaction signing (Ethereum, Solana, BNB)",
3
+ "version": "2.0.0",
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",
7
7
  "license": "MIT",
package/src/index.ts CHANGED
@@ -1,75 +1,70 @@
1
1
  import { requireNativeModule } from "expo-modules-core";
2
2
 
3
- export type SupportedCoin = "ethereum" | "solana" | "bnb";
3
+ /**
4
+ * Mnemonic/private-key material never crosses this boundary except `exportMnemonic` —
5
+ * an explicit, biometric/passcode-gated backup flow. Every other function returns only
6
+ * walletIds, addresses, or signed transaction bytes/hex; wallet storage and all signing
7
+ * happen entirely inside the native module (see ios/TrustWalletCoreModule.swift,
8
+ * android/.../TrustWalletCoreModule.kt).
9
+ */
10
+ export type Chain =
11
+ | "ethereum"
12
+ | "bnb"
13
+ | "polygon" // shares Ethereum's secp256k1 key/address — same BIP44 path, no distinct derivation
14
+ | "solana"
15
+ | "tron"
16
+ | "ton"
17
+ | "bitcoin"
18
+ | "bitcoincash" // address derivation only — sending is unsupported, see ChainSigner
19
+ | "litecoin"
20
+ | "xrp";
4
21
 
5
- export type CoinAccount = {
6
- address: string;
7
- privateKey: string;
22
+ export type WalletSummary = {
23
+ walletId: string;
24
+ addresses: Record<Chain, string>;
8
25
  };
9
26
 
10
- export type WalletResult = {
11
- mnemonic: string;
12
- wallets: Record<SupportedCoin, string>;
27
+ export type SignResult = {
28
+ signedTx: string;
29
+ meta?: Record<string, unknown>;
13
30
  };
14
31
 
15
32
  const TrustWalletCore = requireNativeModule("TrustWalletCore");
16
33
 
17
- // ─── Native calls ────────────────────────────────────────────────────────────
34
+ /** Generates a new mnemonic and persists it natively (biometry/passcode-gated).
35
+ * strength 128 = 12 words, 256 = 24 words. */
36
+ export async function createWallet(strength: 128 | 256 = 128, passphrase = ""): Promise<WalletSummary> {
37
+ return TrustWalletCore.createWallet(strength, passphrase);
38
+ }
39
+
40
+ /** One-time mnemonic exposure from the caller — persisted natively immediately, never
41
+ * retained in JS after this call returns. */
42
+ export async function importWallet(mnemonic: string, passphrase = ""): Promise<WalletSummary> {
43
+ return TrustWalletCore.importWallet(mnemonic, passphrase);
44
+ }
45
+
46
+ /** Reads only public metadata (walletId + addresses) — no biometric prompt. */
47
+ export async function listWallets(): Promise<WalletSummary[]> {
48
+ return TrustWalletCore.listWallets();
49
+ }
18
50
 
19
- /** Create a new HD wallet and store encrypted keys for all supported coins */
20
- export async function generateWallet(
21
- strength = 128,
22
- passphrase = "",
23
- ): Promise<WalletResult> {
24
- const { mnemonic } = (await TrustWalletCore.generateWallet(
25
- strength,
26
- passphrase,
27
- )) as { mnemonic: string };
28
- const coins: SupportedCoin[] = ["ethereum", "solana", "bnb"];
29
- const entries = await Promise.all(
30
- coins.map(async (coin) => {
31
- const { address } = await getAddressForCoin(
32
- mnemonic,
33
- coin,
34
- passphrase,
35
- );
36
- return [coin, address] as const;
37
- }),
38
- );
39
- return {
40
- mnemonic,
41
- wallets: Object.fromEntries(entries) as Record<SupportedCoin, string>,
42
- };
51
+ export async function deleteWallet(walletId: string): Promise<void> {
52
+ return TrustWalletCore.deleteWallet(walletId);
43
53
  }
44
54
 
45
- /** Validate and restore an existing wallet from a BIP-39 mnemonic */
46
- export async function restoreWallet(
47
- mnemonic: string,
48
- passphrase = "",
49
- ): Promise<WalletResult> {
50
- await TrustWalletCore.restoreWallet(mnemonic, passphrase);
51
- const coins: SupportedCoin[] = ["ethereum", "solana", "bnb"];
52
- const entries = await Promise.all(
53
- coins.map(async (coin) => {
54
- const { address } = await getAddressForCoin(
55
- mnemonic,
56
- coin,
57
- passphrase,
58
- );
59
- return [coin, address] as const;
60
- }),
61
- );
62
- return {
63
- mnemonic,
64
- wallets: Object.fromEntries(entries) as Record<SupportedCoin, string>,
65
- };
55
+ /** Triggers the native biometry/passcode prompt, then signs entirely in-process
56
+ * only signed transaction bytes/hex cross back. */
57
+ export async function signTransaction(
58
+ walletId: string,
59
+ chain: Chain,
60
+ unsignedTx: Record<string, unknown>,
61
+ ): Promise<SignResult> {
62
+ return TrustWalletCore.signTransaction(walletId, chain, unsignedTx);
66
63
  }
67
64
 
68
- /** Derive the address and private key for a specific coin from a mnemonic */
69
- export function getAddressForCoin(
70
- mnemonic: string,
71
- coin: SupportedCoin,
72
- passphrase = "",
73
- ): Promise<CoinAccount> {
74
- return TrustWalletCore.getAddressForCoin(mnemonic, coin, passphrase);
65
+ /** The one sanctioned mnemonic exposure explicit backup/reveal flow only, gated behind
66
+ * a native biometry/passcode prompt. Callers must not hold onto the result beyond the
67
+ * immediate display/dismiss of the backup screen. */
68
+ export async function exportMnemonic(walletId: string): Promise<string> {
69
+ return TrustWalletCore.exportMnemonic(walletId);
75
70
  }