@chainberry/trust-wallet-core 1.0.2 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/{TrustWalletCoreModule.podspec → ChainberryTrustWalletCoreModule.podspec} +2 -2
  2. package/README.md +38 -30
  3. package/android/build.gradle +31 -6
  4. package/android/libs/README.md +34 -0
  5. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar +0 -0
  6. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar.md5 +1 -0
  7. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar.sha1 +1 -0
  8. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom +22 -0
  9. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom.md5 +1 -0
  10. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom.sha1 +1 -0
  11. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar +0 -0
  12. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar.md5 +1 -0
  13. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar.sha1 +1 -0
  14. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom +21 -0
  15. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom.md5 +1 -0
  16. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom.sha1 +1 -0
  17. package/android/libs/download.sh +52 -0
  18. package/android/src/androidTest/java/com/chainberry/trustwalletcore/AddressDerivationConformanceTest.kt +106 -0
  19. package/android/src/androidTest/java/com/chainberry/trustwalletcore/SigningConformanceTest.kt +186 -0
  20. package/android/src/main/java/com/chainberry/trustwalletcore/AmountParsing.kt +45 -0
  21. package/android/src/main/java/com/chainberry/trustwalletcore/Bech32.kt +68 -0
  22. package/android/src/main/java/com/chainberry/trustwalletcore/ChainSigning.kt +526 -0
  23. package/android/src/main/java/com/chainberry/trustwalletcore/ChainberryTrustWalletCoreModule.kt +147 -0
  24. package/android/src/main/java/com/chainberry/trustwalletcore/NativeWalletStore.kt +796 -0
  25. package/android/src/test/java/com/chainberry/trustwalletcore/AmountParsingConformanceTest.kt +57 -0
  26. package/android/src/test/java/com/chainberry/trustwalletcore/Bech32Test.kt +35 -0
  27. package/android/src/test/java/com/chainberry/trustwalletcore/NativeWalletStoreTest.kt +274 -0
  28. package/expo-module.config.json +3 -2
  29. package/ios/AmountParsing.swift +62 -0
  30. package/ios/Bech32.swift +66 -0
  31. package/ios/ChainSigning.swift +602 -0
  32. package/ios/ChainberryTrustWalletCoreModule.swift +231 -0
  33. package/ios/NativeWalletStore.swift +232 -0
  34. package/package.json +5 -4
  35. package/src/index.ts +70 -60
  36. package/android/src/main/java/expo/modules/trustwalletcore/TrustWalletCoreModule.kt +0 -143
  37. package/ios/TrustWalletCoreModule.swift +0 -145
@@ -0,0 +1,526 @@
1
+ package com.chainberry.trustwalletcore
2
+
3
+ import android.util.Log
4
+ import com.google.protobuf.ByteString
5
+ import org.json.JSONObject
6
+ import wallet.core.java.AnySigner
7
+ import wallet.core.jni.BitcoinAddress
8
+ import wallet.core.jni.CoinType
9
+ import wallet.core.jni.DataVector
10
+ import wallet.core.jni.Derivation
11
+ import wallet.core.jni.HDWallet
12
+ import wallet.core.jni.Hash
13
+ import wallet.core.jni.PrivateKey
14
+ import wallet.core.jni.SolanaTransaction
15
+ import wallet.core.jni.TransactionDecoder
16
+ import wallet.core.jni.proto.Bitcoin
17
+ import wallet.core.jni.proto.Common
18
+ import wallet.core.jni.proto.Ethereum
19
+ import wallet.core.jni.proto.Ripple
20
+ import wallet.core.jni.proto.Solana
21
+ import wallet.core.jni.proto.TheOpenNetwork
22
+ import wallet.core.jni.proto.Tron
23
+ import java.math.BigInteger
24
+
25
+ class ChainSigningException(message: String) : Exception(message)
26
+
27
+ // Gated on BuildConfig.DEBUG (false in release builds) rather than a bare Log.d call: UTXO
28
+ // txids/amounts/addresses/fees are financial metadata, and android.util.Log.d writes to logcat
29
+ // unconditionally — there's no default ProGuard/R8 rule stripping it, so an ungated call would
30
+ // genuinely ship in production, not just debug. `msg` is a lambda so the (string-templated)
31
+ // message is never even built in release, not just skipped on write.
32
+ private inline fun debugLog(msg: () -> String) {
33
+ if (BuildConfig.DEBUG) Log.d("ChainSigning", msg())
34
+ }
35
+
36
+ // All chains this module derives addresses for / signs transactions for.
37
+ enum class ChainKey(val coinType: CoinType) {
38
+ ETHEREUM(CoinType.ETHEREUM),
39
+ BNB(CoinType.SMARTCHAIN),
40
+ // All EVM chains share Ethereum's secp256k1 key/address (BIP44 slip44 = 60) — no distinct CoinType.
41
+ POLYGON(CoinType.ETHEREUM),
42
+ AVAX(CoinType.ETHEREUM),
43
+ BASE(CoinType.ETHEREUM),
44
+ ARBITRUM(CoinType.ETHEREUM),
45
+ OPTIMISM(CoinType.ETHEREUM),
46
+ SONIC(CoinType.ETHEREUM),
47
+ SOLANA(CoinType.SOLANA),
48
+ TRON(CoinType.TRON),
49
+ TON(CoinType.TON),
50
+ BITCOIN(CoinType.BITCOIN),
51
+ BITCOINCASH(CoinType.BITCOINCASH),
52
+ DOGECOIN(CoinType.DOGECOIN),
53
+ LITECOIN(CoinType.LITECOIN),
54
+ XRP(CoinType.XRP);
55
+
56
+ val symbol: String get() = when (this) {
57
+ ETHEREUM -> "ETH"; BNB -> "BNB"; POLYGON -> "POL"
58
+ AVAX -> "AVAX"; BASE -> "ETH"; ARBITRUM -> "ETH"; OPTIMISM -> "ETH"; SONIC -> "S"
59
+ SOLANA -> "SOL"; TRON -> "TRX"; TON -> "TON"
60
+ BITCOIN -> "BTC"; BITCOINCASH -> "BCH"; DOGECOIN -> "DOGE"; LITECOIN -> "LTC"; XRP -> "XRP"
61
+ }
62
+
63
+ companion object {
64
+ fun fromJs(raw: String): ChainKey =
65
+ entries.find { it.name.equals(raw, ignoreCase = true) }
66
+ ?: throw ChainSigningException("Unsupported chain: $raw")
67
+ }
68
+ }
69
+
70
+ data class ChainSignResult(val signedTx: String, val meta: Map<String, Any>?)
71
+
72
+ object ChainSigner {
73
+ // SLIP-44 dedicates coin_type 1' to "testnet" for every coin — so a shared literal path
74
+ // would make Litecoin and Bitcoin Cash derive the *same* key (BIP32 derivation only depends
75
+ // on (seed, path, curve), and CoinType alone doesn't perturb it when the path and curve
76
+ // — secp256k1 for both — are identical). Disambiguate by using each coin's own SLIP-44
77
+ // index as the account (3rd) path component. `purpose` follows the usual BIP44/49/84
78
+ // convention (44' legacy, 84' native segwit) matching the address style each coin actually
79
+ // gets below.
80
+ private fun utxoTestnetPath(chain: ChainKey, purpose: Int): String =
81
+ "m/$purpose'/1'/${chain.coinType.slip44Id()}'/0/0"
82
+
83
+ private const val LITECOIN_TESTNET_HRP = "tltc"
84
+
85
+ // Bitcoin Cash testnet legacy P2PKH version byte (0x6F) — same value Bitcoin/Litecoin
86
+ // testnets use for their base58 legacy prefix. BCH has no bech32/cashaddr testnet support in
87
+ // wallet-core (cashaddr is a different, more involved encoding than bech32 — unlike
88
+ // Litecoin below, not reimplemented here), so it stays on this legacy fallback.
89
+ private const val BCH_TESTNET_P2PKH_PREFIX: Byte = 0x6F
90
+
91
+ /** Address for `chain`, honoring `isTestnet`.
92
+ *
93
+ * wallet-core's coin registry only carries a real testnet derivation for Bitcoin
94
+ * (`Derivation.BITCOINTESTNET`, native segwit — same "bc1"→"tb1" style shift as mainnet).
95
+ * Litecoin and Bitcoin Cash have no testnet entry at all (no CoinType, no Derivation):
96
+ * - Litecoin gets a hand-rolled native-segwit bech32 address (see Bech32.kt) — the same
97
+ * style as its own mainnet "ltc1..." address, just hrp "tltc" instead of "ltc". wallet-
98
+ * core's `SegwitAddress` can't do this itself (its HRP is a closed native enum with no
99
+ * "tltc" entry), so this reimplements the encode half of BIP-173 by hand.
100
+ * - Bitcoin Cash gets a legacy P2PKH address instead — a different *style* from its own
101
+ * mainnet cashaddr address (cashaddr testnet isn't implemented), but still a real,
102
+ * correctly-testnet-flagged one.
103
+ */
104
+ fun addressForChain(wallet: HDWallet, chain: ChainKey, isTestnet: Boolean): String {
105
+ if (!isTestnet) return wallet.getAddressForCoin(chain.coinType)
106
+ return when (chain) {
107
+ ChainKey.BITCOIN -> wallet.getAddressDerivation(CoinType.BITCOIN, Derivation.BITCOINTESTNET)
108
+ ChainKey.LITECOIN -> {
109
+ val pubKey = keyForChain(wallet, chain, isTestnet = true).getPublicKeySecp256k1(true)
110
+ val program = Hash.sha256RIPEMD(pubKey.data())
111
+ Bech32.encodeSegwitV0(LITECOIN_TESTNET_HRP, program)
112
+ }
113
+ ChainKey.BITCOINCASH -> {
114
+ val pubKey = keyForChain(wallet, chain, isTestnet = true).getPublicKeySecp256k1(true)
115
+ BitcoinAddress(pubKey, BCH_TESTNET_P2PKH_PREFIX).description()
116
+ }
117
+ // Everything else (EVM/Solana/Tron/Ton/Xrp) shares one address format across
118
+ // mainnet/testnet — only the RPC endpoint differs, which lives entirely in JS.
119
+ else -> wallet.getAddressForCoin(chain.coinType)
120
+ }
121
+ }
122
+
123
+ /** The signing key for `chain`, honoring `isTestnet` — must always derive the same key
124
+ * `addressForChain` used, or a UTXO signer builds a transaction that can't spend the
125
+ * wallet's own funds (wrong key ⇒ different scriptPubKey than what's actually sitting at
126
+ * the receive address it was given). */
127
+ private fun keyForChain(wallet: HDWallet, chain: ChainKey, isTestnet: Boolean): PrivateKey {
128
+ if (!isTestnet) return wallet.getKeyForCoin(chain.coinType)
129
+ return when (chain) {
130
+ ChainKey.BITCOIN -> wallet.getKeyDerivation(CoinType.BITCOIN, Derivation.BITCOINTESTNET)
131
+ ChainKey.LITECOIN -> wallet.getKey(CoinType.LITECOIN, utxoTestnetPath(chain, purpose = 84))
132
+ ChainKey.BITCOINCASH -> wallet.getKey(CoinType.BITCOINCASH, utxoTestnetPath(chain, purpose = 44))
133
+ else -> wallet.getKeyForCoin(chain.coinType)
134
+ }
135
+ }
136
+
137
+ fun sign(chain: ChainKey, wallet: HDWallet, unsignedTx: Map<String, Any>, isTestnet: Boolean): ChainSignResult = when (chain) {
138
+ ChainKey.ETHEREUM, ChainKey.BNB, ChainKey.POLYGON,
139
+ ChainKey.AVAX, ChainKey.BASE, ChainKey.ARBITRUM, ChainKey.OPTIMISM, ChainKey.SONIC ->
140
+ ChainSignResult(signEvm(wallet, chain.coinType, unsignedTx), null)
141
+ ChainKey.SOLANA -> ChainSignResult(signSolana(wallet, unsignedTx), null)
142
+ ChainKey.BITCOIN, ChainKey.DOGECOIN, ChainKey.LITECOIN -> ChainSignResult(signUtxo(wallet, chain, unsignedTx, isTestnet), null)
143
+ ChainKey.TRON -> ChainSignResult(signTron(wallet, unsignedTx), null)
144
+ ChainKey.XRP -> ChainSignResult(signXrp(wallet, unsignedTx), null)
145
+ ChainKey.TON -> signTon(wallet, unsignedTx)
146
+ ChainKey.BITCOINCASH -> ChainSignResult(signBch(wallet, unsignedTx), null)
147
+ }
148
+
149
+ // MARK: - EVM (ethereum / bnb / polygon)
150
+ // unsignedTx: { to, chainId, nonce, gasLimitHex, valueHex?, dataHex?,
151
+ // gasPriceHex? (legacy) | maxFeePerGasHex + maxPriorityFeePerGasHex (EIP-1559) }
152
+
153
+ private fun signEvm(wallet: HDWallet, coin: CoinType, unsignedTx: Map<String, Any>): String {
154
+ val privateKey = wallet.getKeyForCoin(coin)
155
+ val to = unsignedTx["to"] as? String ?: throw ChainSigningException("Missing to")
156
+ val nonce = (unsignedTx["nonce"] as? Number)?.toLong() ?: throw ChainSigningException("Missing nonce")
157
+ val gasLimHex = unsignedTx["gasLimitHex"] as? String ?: throw ChainSigningException("Missing gasLimitHex")
158
+ val chainId = (unsignedTx["chainId"] as? Number)?.toLong() ?: throw ChainSigningException("Missing chainId")
159
+ val valueHex = (unsignedTx["valueHex"] as? String)?.ifEmpty { "0" } ?: "0"
160
+ val dataHex = unsignedTx["dataHex"] as? String ?: ""
161
+
162
+ val input = Ethereum.SigningInput.newBuilder().apply {
163
+ this.chainId = BigInteger.valueOf(chainId).toMinimalByteString()
164
+ this.nonce = BigInteger.valueOf(nonce).toMinimalByteString()
165
+ this.gasLimit = BigInteger(gasLimHex, 16).toMinimalByteString()
166
+ this.toAddress = to
167
+ this.privateKey = ByteString.copyFrom(privateKey.data())
168
+
169
+ this.transaction = Ethereum.Transaction.newBuilder().apply {
170
+ this.transfer = Ethereum.Transaction.Transfer.newBuilder().apply {
171
+ this.amount = BigInteger(valueHex, 16).toMinimalByteString()
172
+ if (dataHex.isNotEmpty()) this.data = ByteString.copyFrom(dataHex.hexToBytes())
173
+ }.build()
174
+ }.build()
175
+
176
+ val gasPriceHex = unsignedTx["gasPriceHex"] as? String
177
+ if (gasPriceHex != null) {
178
+ this.gasPrice = BigInteger(gasPriceHex, 16).toMinimalByteString()
179
+ } else {
180
+ val mfHex = unsignedTx["maxFeePerGasHex"] as? String
181
+ val pfHex = unsignedTx["maxPriorityFeePerGasHex"] as? String
182
+ if (mfHex != null && pfHex != null) {
183
+ this.txMode = Ethereum.TransactionMode.Enveloped
184
+ this.maxFeePerGas = BigInteger(mfHex, 16).toMinimalByteString()
185
+ this.maxInclusionFeePerGas = BigInteger(pfHex, 16).toMinimalByteString()
186
+ }
187
+ }
188
+ }.build()
189
+
190
+ val output = AnySigner.sign(input, coin, Ethereum.SigningOutput.parser())
191
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
192
+ return "0x" + output.encoded.toByteArray().toHex()
193
+ }
194
+
195
+ // MARK: - Solana
196
+ // unsignedTx: { unsignedTxBase64: string } — a fully-built, base64-serialized unsigned
197
+ // @solana/web3.js Transaction (native transfer or SPL-token transfer) from
198
+ // prepareSelfCustodyUnsignedTx's SOL branch, already carrying a recentBlockhash/feePayer.
199
+ // wallet-core has no "sign these raw bytes as-is" entry point for Solana — RawMessage is a
200
+ // structured legacy/v0 message, not an opaque blob. `updateBlockhashAndSign` is TW's own
201
+ // helper for signing an already-built web3.js transaction: it takes the base64 tx, a
202
+ // recent blockhash, and a set of private keys, and re-serializes+signs. We don't have a
203
+ // fresher blockhash than the one already embedded, so decode the tx to read it back out
204
+ // (via TransactionDecoder) and hand it straight back in — a no-op "update" that still goes
205
+ // through the sanctioned signing path.
206
+ private fun signSolana(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
207
+ val privateKey = wallet.getKeyForCoin(CoinType.SOLANA)
208
+ val unsignedTxBase64 = unsignedTx["unsignedTxBase64"] as? String
209
+ ?: throw ChainSigningException("Missing unsignedTxBase64")
210
+ val txBytes = android.util.Base64.decode(unsignedTxBase64, android.util.Base64.NO_WRAP)
211
+
212
+ val decoded = Solana.DecodingTransactionOutput.parseFrom(TransactionDecoder.decode(CoinType.SOLANA, txBytes))
213
+ if (decoded.error != Common.SigningError.OK) {
214
+ throw ChainSigningException("Failed to decode Solana tx: ${decoded.errorMessage}")
215
+ }
216
+ val recentBlockhash = decoded.transaction.legacy.recentBlockhash
217
+
218
+ val privateKeys = DataVector()
219
+ privateKeys.add(privateKey.data())
220
+
221
+ val output = Solana.SigningOutput.parseFrom(
222
+ SolanaTransaction.updateBlockhashAndSign(unsignedTxBase64, recentBlockhash, privateKeys)
223
+ )
224
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
225
+ return output.encoded
226
+ }
227
+
228
+ // MARK: - BCH (UTXO-based, replay-protected)
229
+ // unsignedTx: { unsignedDescriptorJson: string }
230
+ // descriptor (from wallet-broadcast's prepareBchTransaction):
231
+ // { inputs: [{ txid, vout, satoshis, scriptPubKeyHex }], toAddress, sendAmountSats,
232
+ // changeAddress?, changeSats? }
233
+ // BCH uses SIGHASH_ALL | SIGHASH_FORK_ID (0x41) for replay protection.
234
+ private fun signBch(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
235
+ val descriptorJson = unsignedTx["unsignedDescriptorJson"] as? String
236
+ ?: throw ChainSigningException("Missing unsignedDescriptorJson for BCH")
237
+
238
+ val descriptor = JSONObject(descriptorJson)
239
+ val toAddress = descriptor.getString("toAddress")
240
+ val sendAmountSats = descriptor.getLong("sendAmountSats")
241
+ val changeAddress = if (descriptor.has("changeAddress")) descriptor.getString("changeAddress") else null
242
+ val inputsJson = descriptor.getJSONArray("inputs")
243
+
244
+ val privateKey = wallet.getKeyForCoin(CoinType.BITCOINCASH)
245
+
246
+ val utxos = (0 until inputsJson.length()).map { i ->
247
+ val entry = inputsJson.getJSONObject(i)
248
+ val txIdHex = entry.getString("txid")
249
+ val vout = entry.getInt("vout")
250
+ val satoshis = entry.getLong("satoshis")
251
+ val scriptHex = entry.getString("scriptPubKeyHex")
252
+ val txIdBytes = txIdHex.hexToBytes().reversedArray()
253
+
254
+ Bitcoin.UnspentTransaction.newBuilder().apply {
255
+ this.outPoint = Bitcoin.OutPoint.newBuilder().apply {
256
+ this.hash = ByteString.copyFrom(txIdBytes)
257
+ this.index = vout
258
+ }.build()
259
+ this.amount = satoshis
260
+ this.script = ByteString.copyFrom(scriptHex.hexToBytes())
261
+ }.build()
262
+ }
263
+
264
+ val input = Bitcoin.SigningInput.newBuilder().apply {
265
+ this.hashType = 0x41 // SIGHASH_ALL | SIGHASH_FORK_ID (BCH replay protection)
266
+ this.amount = sendAmountSats
267
+ this.byteFee = 1 // BCH fees are minimal; 1 sat/byte
268
+ this.toAddress = toAddress
269
+ if (changeAddress != null) this.changeAddress = changeAddress
270
+ this.useMaxAmount = false
271
+ this.coinType = CoinType.BITCOINCASH.value()
272
+ this.addPrivateKey(ByteString.copyFrom(privateKey.data()))
273
+ this.addAllUtxo(utxos)
274
+ }.build()
275
+
276
+ val output = AnySigner.sign(input, CoinType.BITCOINCASH, Bitcoin.SigningOutput.parser())
277
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("BCH signing failed: ${output.errorMessage}")
278
+ return output.encoded.toByteArray().toHex()
279
+ }
280
+
281
+ // MARK: - BTC / LTC (UTXO-based)
282
+ // unsignedTx (from wallet-broadcast's additive prepareBtcUtxoSet/prepareLtcUtxoSet):
283
+ // { toAddress, changeAddress, sendAmountSats, changeAmountSats, satsPerByte,
284
+ // inputs: [{ txIdHex, vout, amountSats, scriptPubKeyHex }] }
285
+ @Suppress("UNCHECKED_CAST")
286
+ private fun signUtxo(wallet: HDWallet, chain: ChainKey, unsignedTx: Map<String, Any>, isTestnet: Boolean): String {
287
+ val coin = chain.coinType
288
+ val privateKey = keyForChain(wallet, chain, isTestnet)
289
+ val toAddress = unsignedTx["toAddress"] as? String ?: throw ChainSigningException("Missing toAddress")
290
+ val changeAddress = unsignedTx["changeAddress"] as? String ?: throw ChainSigningException("Missing changeAddress")
291
+ val sendAmountSats = (unsignedTx["sendAmountSats"] as? String)?.toLong() ?: throw ChainSigningException("Missing sendAmountSats")
292
+ val satsPerByte = (unsignedTx["satsPerByte"] as? Number)?.toLong() ?: throw ChainSigningException("Missing satsPerByte")
293
+ val inputs = unsignedTx["inputs"] as? List<Map<String, Any>> ?: throw ChainSigningException("Missing inputs")
294
+
295
+ debugLog { "signUtxo: parsing ${inputs.size} UTXOs" }
296
+ val utxos = inputs.map { entry ->
297
+ val txIdHex = entry["txIdHex"] as? String ?: throw ChainSigningException("Invalid UTXO entry: missing txIdHex, keys=${entry.keys}")
298
+ val vout = (entry["vout"] as? Number)?.toInt() ?: throw ChainSigningException("Invalid UTXO entry: missing vout")
299
+ val amount = (entry["amountSats"] as? String)?.toLong() ?: throw ChainSigningException("Invalid UTXO entry: missing amountSats, type=${entry["amountSats"]?.javaClass?.name}")
300
+ val scriptHex = entry["scriptPubKeyHex"] as? String ?: throw ChainSigningException("Invalid UTXO entry: missing scriptPubKeyHex")
301
+ debugLog { "signUtxo: UTXO txid=$txIdHex vout=$vout amount=$amount" }
302
+
303
+ // On-chain/explorer txid hex is displayed big-endian; wallet-core's OutPoint.hash wants
304
+ // the reversed (little-endian, internal wire-format) byte order.
305
+ val txIdBytes = txIdHex.hexToBytes().reversedArray()
306
+
307
+ Bitcoin.UnspentTransaction.newBuilder().apply {
308
+ this.outPoint = Bitcoin.OutPoint.newBuilder().apply {
309
+ this.hash = ByteString.copyFrom(txIdBytes)
310
+ this.index = vout
311
+ }.build()
312
+ this.amount = amount
313
+ this.script = ByteString.copyFrom(scriptHex.hexToBytes())
314
+ }.build()
315
+ }
316
+
317
+ debugLog { "signUtxo: building SigningInput toAddress=$toAddress sats=$sendAmountSats fee=$satsPerByte" }
318
+ val input = Bitcoin.SigningInput.newBuilder().apply {
319
+ this.hashType = 1 // SIGHASH_ALL — stable Bitcoin protocol constant, not a wallet-core-specific value
320
+ this.amount = sendAmountSats
321
+ this.byteFee = satsPerByte
322
+ this.toAddress = toAddress
323
+ this.changeAddress = changeAddress
324
+ this.useMaxAmount = false
325
+ this.coinType = coin.value()
326
+ this.addPrivateKey(ByteString.copyFrom(privateKey.data()))
327
+ this.addAllUtxo(utxos)
328
+ }.build()
329
+
330
+ debugLog { "signUtxo: calling AnySigner.sign coin=${coin.name}" }
331
+ val output = AnySigner.sign(input, coin, Bitcoin.SigningOutput.parser())
332
+ debugLog { "signUtxo: AnySigner.sign done error=${output.error} msg=${output.errorMessage}" }
333
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
334
+ return output.encoded.toByteArray().toHex()
335
+ }
336
+
337
+ // MARK: - TRX
338
+ // unsignedTx: the raw TronGrid-shaped unsigned tx object from prepareSelfCustodyUnsignedTx's
339
+ // TRX branch (TronWeb's transactionBuilder output — raw_data, raw_data_hex, txID). Building a
340
+ // Tron.Transaction proto from scratch would need the *full* source BlockHeader (parent hash,
341
+ // tx trie root, witness address, version): wallet-core hashes that header itself to derive
342
+ // ref_block_bytes/ref_block_hash, and TronGrid only ever hands us those two derived fields
343
+ // pre-computed, not the header they came from — so there's no way to reconstruct one that
344
+ // rehashes to the same values. Tron.SigningInput's `txId` field exists for exactly this case
345
+ // (see its proto doc: "direct sign in Tron, we just have to sign the txId returned by the
346
+ // DApp json payload") — it signs the given digest as-is and skips transaction rebuilding
347
+ // entirely, which also means TRC20 `triggerSmartContract` payloads are covered for free, not
348
+ // just plain transfers. wallet-core's direct-sign path only returns the raw signature (no
349
+ // `json`), so the signed tx is assembled here the same way TronWeb does: original tx + a
350
+ // `signature` array — the shape broadcastTrxTransaction's tronWeb.trx.sendRawTransaction expects.
351
+ private fun signTron(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
352
+ val privateKey = wallet.getKeyForCoin(CoinType.TRON)
353
+ val txId = unsignedTx["txID"] as? String ?: throw ChainSigningException("Missing txID")
354
+
355
+ val input = Tron.SigningInput.newBuilder().apply {
356
+ this.txId = txId
357
+ this.privateKey = ByteString.copyFrom(privateKey.data())
358
+ }.build()
359
+
360
+ val output = AnySigner.sign(input, CoinType.TRON, Tron.SigningOutput.parser())
361
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
362
+
363
+ val signatureHex = output.signature.toByteArray().toHex()
364
+ val signedTx = JSONObject(unsignedTx).put("signature", org.json.JSONArray().put(signatureHex))
365
+ return signedTx.toString()
366
+ }
367
+
368
+ // MARK: - XRP
369
+ // unsignedTx: xrpl.js `Payment` object (Account, Destination, Amount (drops, string),
370
+ // Fee (drops, string), Sequence, LastLedgerSequence, DestinationTag?).
371
+ private fun signXrp(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
372
+ val privateKey = wallet.getKeyForCoin(CoinType.XRP)
373
+ val account = unsignedTx["Account"] as? String ?: throw ChainSigningException("Missing Account")
374
+ val destination = unsignedTx["Destination"] as? String ?: throw ChainSigningException("Missing Destination")
375
+ val amountDrops = unsignedTx["Amount"] as? String ?: throw ChainSigningException("Missing Amount")
376
+ val feeDrops = unsignedTx["Fee"] as? String ?: throw ChainSigningException("Missing Fee")
377
+ val sequence = (unsignedTx["Sequence"] as? Number)?.toInt() ?: throw ChainSigningException("Missing Sequence")
378
+ val lastLedgerSequence = (unsignedTx["LastLedgerSequence"] as? Number)?.toInt()
379
+ val destinationTag = parseXrpDestinationTag((unsignedTx["DestinationTag"] as? Number)?.toLong())
380
+
381
+ val input = Ripple.SigningInput.newBuilder().apply {
382
+ this.privateKey = ByteString.copyFrom(privateKey.data())
383
+ this.account = account
384
+ this.fee = parseXrpFeeDrops(feeDrops)
385
+ this.sequence = sequence
386
+ lastLedgerSequence?.let { this.lastLedgerSequence = it }
387
+ this.opPayment = Ripple.OperationPayment.newBuilder().apply {
388
+ this.amount = parseXrpAmountDrops(amountDrops)
389
+ this.destination = destination
390
+ destinationTag?.let { this.destinationTag = it }
391
+ }.build()
392
+ }.build()
393
+
394
+ val output = AnySigner.sign(input, CoinType.XRP, Ripple.SigningOutput.parser())
395
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
396
+ return output.encoded.toByteArray().toHex()
397
+ }
398
+
399
+ // MARK: - TON
400
+ // unsignedTx: { toAddress, amount, seqno, memoId? }.
401
+ // NOTE(verify-on-device): confirm `amount` units (nanoton vs TON) and that wallet_version
402
+ // V4R2 matches the address format the current @ton/* implementation derives.
403
+ // expireAt isn't supplied by prepareSelfCustodyUnsignedTx's TON branch (just
404
+ // {toAddress,amount,seqno,memoId}); default to a 10-minute signing window, matching the
405
+ // extendExpiration(tx, 600) buffer TRX's prepare step already uses.
406
+ private fun signTon(wallet: HDWallet, unsignedTx: Map<String, Any>): ChainSignResult {
407
+ val privateKey = wallet.getKeyForCoin(CoinType.TON)
408
+ val toAddress = unsignedTx["toAddress"] as? String ?: throw ChainSigningException("Missing toAddress")
409
+ val amountStr = unsignedTx["amount"] as? String ?: throw ChainSigningException("Missing amount")
410
+ val amount = parseTonNanotons(amountStr)
411
+ val seqno = (unsignedTx["seqno"] as? Number)?.toInt() ?: throw ChainSigningException("Missing seqno")
412
+ val memoId = unsignedTx["memoId"] as? String
413
+ val expireAt = (System.currentTimeMillis() / 1000L + 600L).toInt()
414
+
415
+ val transfer = TheOpenNetwork.Transfer.newBuilder().apply {
416
+ this.dest = toAddress
417
+ this.amount = amount
418
+ this.mode = TheOpenNetwork.SendMode.PAY_FEES_SEPARATELY_VALUE or TheOpenNetwork.SendMode.IGNORE_ACTION_PHASE_ERRORS_VALUE
419
+ this.bounceable = true
420
+ memoId?.let { this.comment = it }
421
+ }.build()
422
+
423
+ val input = TheOpenNetwork.SigningInput.newBuilder().apply {
424
+ this.privateKey = ByteString.copyFrom(privateKey.data())
425
+ this.walletVersion = TheOpenNetwork.WalletVersion.WALLET_V4_R2
426
+ this.sequenceNumber = seqno
427
+ this.expireAt = expireAt
428
+ this.addMessages(transfer)
429
+ }.build()
430
+
431
+ val output = AnySigner.sign(input, CoinType.TON, TheOpenNetwork.SigningOutput.parser())
432
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
433
+ return ChainSignResult(output.encoded, mapOf("txHash" to output.hash.toByteArray().toHex()))
434
+ }
435
+ }
436
+
437
+ // Transaction summary helpers (used by ChainberryTrustWalletCoreModule for native confirmation UI)
438
+
439
+ internal fun ChainSigner.buildSummary(chain: ChainKey, unsignedTx: Map<String, Any>): String {
440
+ val lines = mutableListOf("Network: ${chain.name}")
441
+ when (chain) {
442
+ ChainKey.ETHEREUM, ChainKey.BNB, ChainKey.POLYGON,
443
+ ChainKey.AVAX, ChainKey.BASE, ChainKey.ARBITRUM, ChainKey.OPTIMISM, ChainKey.SONIC -> {
444
+ (unsignedTx["to"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
445
+ val valueWei = txHexToDouble((unsignedTx["valueHex"] as? String) ?: "0")
446
+ lines += "Amount: ${fmtAmt(valueWei / 1e18)} ${chain.symbol}"
447
+ val gasLimit = txHexToDouble((unsignedTx["gasLimitHex"] as? String) ?: "0")
448
+ val gasPrice = txHexToDouble(
449
+ (unsignedTx["gasPriceHex"] as? String) ?: (unsignedTx["maxFeePerGasHex"] as? String) ?: "0"
450
+ )
451
+ val feeWei = gasLimit * gasPrice
452
+ if (feeWei > 0) lines += "Max fee: ${fmtAmt(feeWei / 1e18)} ${chain.symbol}"
453
+ }
454
+ ChainKey.BITCOIN, ChainKey.DOGECOIN, ChainKey.LITECOIN -> {
455
+ (unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
456
+ (unsignedTx["sendAmountSats"] as? String)?.toLongOrNull()?.let {
457
+ lines += "Amount: ${fmtAmt(it.toDouble() / 1e8)} ${chain.symbol}"
458
+ }
459
+ }
460
+ ChainKey.XRP -> {
461
+ (unsignedTx["Destination"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
462
+ (unsignedTx["Amount"] as? String)?.toLongOrNull()?.let {
463
+ lines += "Amount: ${fmtAmt(it.toDouble() / 1_000_000.0)} XRP"
464
+ }
465
+ (unsignedTx["Fee"] as? String)?.toLongOrNull()?.let {
466
+ lines += "Fee: ${fmtAmt(it.toDouble() / 1_000_000.0)} XRP"
467
+ }
468
+ unsignedTx["DestinationTag"]?.let { lines += "Tag: $it" }
469
+ }
470
+ ChainKey.TON -> {
471
+ (unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
472
+ (unsignedTx["amount"] as? String)?.toULongOrNull()?.let {
473
+ lines += "Amount: ${fmtAmt(it.toDouble() / 1e9)} TON"
474
+ }
475
+ }
476
+ ChainKey.TRON -> {
477
+ @Suppress("UNCHECKED_CAST")
478
+ val value = ((unsignedTx["raw_data"] as? Map<String, Any>)
479
+ ?.let { (it["contract"] as? List<Map<String, Any>>)?.firstOrNull() }
480
+ ?.let { it["parameter"] as? Map<String, Any> }
481
+ ?.let { it["value"] as? Map<String, Any> })
482
+ value?.let { v ->
483
+ (v["to_address"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
484
+ (v["amount"] as? Number)?.let { lines += "Amount: ${fmtAmt(it.toDouble() / 1e6)} TRX" }
485
+ }
486
+ }
487
+ ChainKey.SOLANA -> lines += "(Solana — details verified by the network)"
488
+ ChainKey.BITCOINCASH -> {
489
+ try {
490
+ val descriptor = JSONObject((unsignedTx["unsignedDescriptorJson"] as? String) ?: "")
491
+ descriptor.optString("toAddress").takeIf { it.isNotEmpty() }?.let { lines += "To: ${fmtAddr(it)}" }
492
+ val sats = descriptor.optLong("sendAmountSats", -1L)
493
+ if (sats >= 0) lines += "Amount: ${fmtAmt(sats.toDouble() / 1e8)} BCH"
494
+ } catch (_: Exception) {}
495
+ }
496
+ }
497
+ return lines.joinToString("\n")
498
+ }
499
+
500
+ private fun txHexToDouble(hex: String): Double = try {
501
+ BigInteger(hex.removePrefix("0x").ifEmpty { "0" }, 16).toDouble()
502
+ } catch (_: NumberFormatException) { 0.0 }
503
+
504
+ private fun fmtAmt(value: Double): String =
505
+ "%.8f".format(value).trimEnd('0').trimEnd('.')
506
+
507
+ private fun fmtAddr(addr: String): String = addr
508
+
509
+ // Helpers
510
+
511
+ private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
512
+
513
+ private fun String.hexToBytes(): ByteArray {
514
+ val s = removePrefix("0x").let { if (it.length % 2 == 0) it else "0$it" }
515
+ return ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
516
+ }
517
+
518
+ // BigInteger -> minimal big-endian ByteString (strips Java's leading sign byte)
519
+ private fun BigInteger.toMinimalByteString(): ByteString {
520
+ val raw = toByteArray()
521
+ return if (raw.size > 1 && raw[0] == 0.toByte()) {
522
+ ByteString.copyFrom(raw, 1, raw.size - 1)
523
+ } else {
524
+ ByteString.copyFrom(raw)
525
+ }
526
+ }
@@ -0,0 +1,147 @@
1
+ package com.chainberry.trustwalletcore
2
+
3
+ import android.app.AlertDialog
4
+ import androidx.fragment.app.FragmentActivity
5
+ import expo.modules.kotlin.exception.CodedException
6
+ import expo.modules.kotlin.functions.Coroutine
7
+ import expo.modules.kotlin.modules.Module
8
+ import expo.modules.kotlin.modules.ModuleDefinition
9
+ import kotlinx.coroutines.suspendCancellableCoroutine
10
+ import wallet.core.jni.HDWallet
11
+ import java.util.UUID
12
+ import kotlin.coroutines.resume
13
+ import kotlin.coroutines.resumeWithException
14
+
15
+ // Mnemonic/private-key material never crosses back to JS except `exportMnemonic` — an
16
+ // explicit, biometric/device-credential-gated backup flow. Every other method returns only
17
+ // walletIds, addresses, or signed transaction bytes/hex.
18
+ class ChainberryTrustWalletCoreModule : Module() {
19
+ companion object {
20
+ init {
21
+ // Must be loaded once before any JNI calls
22
+ System.loadLibrary("TrustWalletCore")
23
+ }
24
+ }
25
+
26
+ private val context get() = appContext.reactContext
27
+ ?: throw CodedException("NoContext", "React context unavailable", null)
28
+
29
+ private val activity: FragmentActivity
30
+ get() = appContext.currentActivity as? FragmentActivity
31
+ ?: throw CodedException("NoActivity", "No foreground FragmentActivity to host the biometric prompt", null)
32
+
33
+ override fun definition() = ModuleDefinition {
34
+ Name("TrustWalletCore")
35
+
36
+ // strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses }.
37
+ // No BIP-39 passphrase support: signTransaction always reconstructs the wallet with an
38
+ // empty passphrase, so accepting one here would derive addresses from a seed different
39
+ // from the one actually used to sign — always pass "" to stay consistent with that.
40
+ // isTestnet selects the address format for BTC/LTC/BCH (see ChainSigner.addressForChain) —
41
+ // every other chain's address is the same on mainnet and testnet.
42
+ AsyncFunction("createWallet") Coroutine { strength: Int, isTestnet: Boolean ->
43
+ val wallet = HDWallet(strength, "")
44
+ persistNewWallet(wallet, isTestnet)
45
+ }
46
+
47
+ // One-time mnemonic exposure from JS, at import only — never retained after this call.
48
+ // Returns { walletId, addresses }. No BIP-39 passphrase support (see `createWallet`).
49
+ AsyncFunction("importWallet") Coroutine { mnemonic: String, isTestnet: Boolean ->
50
+ val wallet = HDWallet(mnemonic, "") // throws on invalid mnemonic
51
+ persistNewWallet(wallet, isTestnet)
52
+ }
53
+
54
+ // Reads only the ungated metadata store — no biometric prompt.
55
+ AsyncFunction("listWallets") {
56
+ NativeWalletStore.loadMetadata(context).map { (walletId, addresses) ->
57
+ mapOf("walletId" to walletId, "addresses" to addresses)
58
+ }
59
+ }
60
+
61
+ // Irreversible — requires a fresh biometric/device-credential confirmation before
62
+ // anything is deleted, same gate as `signTransaction`/`exportMnemonic`. A
63
+ // compromised/malicious JS caller can still invoke this directly (there's no UI call
64
+ // site today), so the gate must live here rather than in JS.
65
+ AsyncFunction("deleteWallet") Coroutine { walletId: String ->
66
+ val id = NativeWalletStore.validateWalletId(walletId)
67
+ NativeWalletStore.confirmIdentity(activity, context, "Delete wallet")
68
+ NativeWalletStore.deleteMnemonic(context, id)
69
+ val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
70
+ metadata.remove(id)
71
+ NativeWalletStore.saveMetadata(context, metadata)
72
+ }
73
+
74
+ // Triggers the native biometry/device-credential prompt, then signs entirely in-process.
75
+ // Returns { signedTx, meta? }. isTestnet must match whatever `createWallet`/`importWallet`
76
+ // used — see ChainSigner.keyForChain (a mismatch signs with the wrong key for BTC/LTC).
77
+ AsyncFunction("signTransaction") Coroutine { walletId: String, chain: String, unsignedTx: Map<String, Any>, isTestnet: Boolean ->
78
+ val id = NativeWalletStore.validateWalletId(walletId)
79
+ val chainKey = ChainKey.fromJs(chain)
80
+ confirmTransaction(chainKey, unsignedTx)
81
+ val cipher = NativeWalletStore.authenticateForExistingWallet(activity, context, id, "Sign transaction")
82
+ val mnemonic = NativeWalletStore.loadMnemonic(context, id, cipher)
83
+ val wallet = HDWallet(mnemonic, "")
84
+ val result = ChainSigner.sign(chainKey, wallet, unsignedTx, isTestnet)
85
+ val response = mutableMapOf<String, Any>("signedTx" to result.signedTx)
86
+ result.meta?.let { response["meta"] = it }
87
+ response
88
+ }
89
+
90
+ // The one sanctioned mnemonic exposure — explicit backup flow only.
91
+ AsyncFunction("exportMnemonic") Coroutine { walletId: String ->
92
+ val id = NativeWalletStore.validateWalletId(walletId)
93
+ val cipher = NativeWalletStore.authenticateForExistingWallet(activity, context, id, "Reveal recovery phrase")
94
+ NativeWalletStore.loadMnemonic(context, id, cipher)
95
+ }
96
+ }
97
+
98
+ /// Shows a native AlertDialog with decoded tx details before biometric auth fires.
99
+ /// The user must tap "Confirm & Sign" — cancelling throws UserCancelled.
100
+ private suspend fun confirmTransaction(chain: ChainKey, unsignedTx: Map<String, Any>) {
101
+ val message = ChainSigner.buildSummary(chain, unsignedTx)
102
+ suspendCancellableCoroutine<Unit> { continuation ->
103
+ activity.runOnUiThread {
104
+ AlertDialog.Builder(activity)
105
+ .setTitle("Confirm Transaction")
106
+ .setMessage(message)
107
+ .setPositiveButton("Confirm & Sign") { _, _ -> continuation.resume(Unit) }
108
+ .setNegativeButton("Cancel") { _, _ ->
109
+ continuation.resumeWithException(
110
+ CodedException("UserCancelled", "Transaction cancelled by user", null)
111
+ )
112
+ }
113
+ .setOnCancelListener {
114
+ continuation.resumeWithException(
115
+ CodedException("UserCancelled", "Transaction cancelled by user", null)
116
+ )
117
+ }
118
+ .show()
119
+ }
120
+ }
121
+ }
122
+
123
+ private suspend fun persistNewWallet(wallet: HDWallet, isTestnet: Boolean): Map<String, Any> {
124
+ val walletId = UUID.randomUUID().toString()
125
+ val addresses = ChainKey.entries.associate { chain ->
126
+ chain.name.lowercase() to ChainSigner.addressForChain(wallet, chain, isTestnet)
127
+ }
128
+
129
+ val cipher = NativeWalletStore.authenticateForNewWallet(activity, context, walletId, "Secure your new wallet")
130
+ NativeWalletStore.saveMnemonic(context, walletId, wallet.mnemonic(), cipher)
131
+
132
+ try {
133
+ val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
134
+ metadata[walletId] = addresses
135
+ NativeWalletStore.saveMetadata(context, metadata)
136
+ } catch (e: Exception) {
137
+ // The mnemonic/key is already persisted but has no metadata pointer — compensate by
138
+ // best-effort deleting it rather than leaving a permanent, invisible orphan. If this
139
+ // rollback delete also fails, there's nothing more useful to do than propagate the
140
+ // original error; the wallet is at least no worse off than before this call.
141
+ runCatching { NativeWalletStore.deleteMnemonic(context, walletId) }
142
+ throw e
143
+ }
144
+
145
+ return mapOf("walletId" to walletId, "addresses" to addresses)
146
+ }
147
+ }