@chainberry/trust-wallet-core 2.0.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/{TrustWalletCoreModule.podspec → ChainberryTrustWalletCoreModule.podspec} +5 -4
  2. package/README.md +27 -25
  3. package/android/build.gradle +29 -6
  4. package/android/libs/README.md +34 -0
  5. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar +0 -0
  6. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar.md5 +1 -0
  7. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar.sha1 +1 -0
  8. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom +22 -0
  9. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom.md5 +1 -0
  10. package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom.sha1 +1 -0
  11. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar +0 -0
  12. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar.md5 +1 -0
  13. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar.sha1 +1 -0
  14. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom +21 -0
  15. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom.md5 +1 -0
  16. package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom.sha1 +1 -0
  17. package/android/libs/download.sh +52 -0
  18. package/android/src/androidTest/java/com/chainberry/trustwalletcore/AddressDerivationConformanceTest.kt +106 -0
  19. package/android/src/androidTest/java/com/chainberry/trustwalletcore/SigningConformanceTest.kt +186 -0
  20. package/android/src/main/java/com/chainberry/trustwalletcore/AmountParsing.kt +45 -0
  21. package/android/src/main/java/com/chainberry/trustwalletcore/Bech32.kt +68 -0
  22. package/android/src/main/java/com/chainberry/trustwalletcore/ChainSigning.kt +884 -0
  23. package/android/src/main/java/com/chainberry/trustwalletcore/ChainberryTrustWalletCoreModule.kt +227 -0
  24. package/android/src/main/java/com/chainberry/trustwalletcore/NativeWalletStore.kt +888 -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 +344 -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 +978 -0
  32. package/ios/ChainberryTrustWalletCoreModule.swift +336 -0
  33. package/ios/ConformanceTests/AddressDerivationConformanceTests.swift +39 -0
  34. package/ios/ConformanceTests/SigningConformanceTests.swift +295 -0
  35. package/ios/NativeWalletStore.swift +288 -0
  36. package/package.json +4 -3
  37. package/src/index.ts +42 -13
  38. package/android/src/main/java/expo/modules/trustwalletcore/ChainSigning.kt +0 -299
  39. package/android/src/main/java/expo/modules/trustwalletcore/NativeWalletStore.kt +0 -182
  40. package/android/src/main/java/expo/modules/trustwalletcore/TrustWalletCoreModule.kt +0 -91
  41. package/ios/TrustWalletCoreModule.swift +0 -107
@@ -0,0 +1,884 @@
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.Aptos
19
+ import wallet.core.jni.proto.Cosmos
20
+ import wallet.core.jni.proto.Tezos
21
+ import wallet.core.jni.proto.Ethereum
22
+ import wallet.core.jni.proto.Ripple
23
+ import wallet.core.jni.proto.Solana
24
+ import wallet.core.jni.proto.TheOpenNetwork
25
+ import wallet.core.jni.proto.Tron
26
+ import java.math.BigInteger
27
+
28
+ class ChainSigningException(message: String) : Exception(message)
29
+
30
+ // Gated on BuildConfig.DEBUG (false in release builds) rather than a bare Log.d call: UTXO
31
+ // txids/amounts/addresses/fees are financial metadata, and android.util.Log.d writes to logcat
32
+ // unconditionally — there's no default ProGuard/R8 rule stripping it, so an ungated call would
33
+ // genuinely ship in production, not just debug. `msg` is a lambda so the (string-templated)
34
+ // message is never even built in release, not just skipped on write.
35
+ private inline fun debugLog(msg: () -> String) {
36
+ if (BuildConfig.DEBUG) Log.d("ChainSigning", msg())
37
+ }
38
+
39
+ // All chains this module derives addresses for / signs transactions for.
40
+ enum class ChainKey(val coinType: CoinType) {
41
+ ETHEREUM(CoinType.ETHEREUM),
42
+ BNB(CoinType.SMARTCHAIN),
43
+ // All EVM chains share Ethereum's secp256k1 key/address (BIP44 slip44 = 60) — no distinct CoinType.
44
+ POLYGON(CoinType.ETHEREUM),
45
+ AVAX(CoinType.ETHEREUM),
46
+ BASE(CoinType.ETHEREUM),
47
+ ARBITRUM(CoinType.ETHEREUM),
48
+ OPTIMISM(CoinType.ETHEREUM),
49
+ SONIC(CoinType.ETHEREUM),
50
+ SOLANA(CoinType.SOLANA),
51
+ TRON(CoinType.TRON),
52
+ TON(CoinType.TON),
53
+ BITCOIN(CoinType.BITCOIN),
54
+ BITCOINCASH(CoinType.BITCOINCASH),
55
+ DOGECOIN(CoinType.DOGECOIN),
56
+ LITECOIN(CoinType.LITECOIN),
57
+ XRP(CoinType.XRP),
58
+ COSMOS(CoinType.COSMOS),
59
+ APTOS(CoinType.APTOS),
60
+ TEZOS(CoinType.TEZOS);
61
+
62
+ val symbol: String get() = when (this) {
63
+ ETHEREUM -> "ETH"; BNB -> "BNB"; POLYGON -> "POL"
64
+ AVAX -> "AVAX"; BASE -> "ETH"; ARBITRUM -> "ETH"; OPTIMISM -> "ETH"; SONIC -> "S"
65
+ SOLANA -> "SOL"; TRON -> "TRX"; TON -> "TON"
66
+ BITCOIN -> "BTC"; BITCOINCASH -> "BCH"; DOGECOIN -> "DOGE"; LITECOIN -> "LTC"; XRP -> "XRP"
67
+ COSMOS -> "ATOM"
68
+ APTOS -> "APT"
69
+ TEZOS -> "XTZ"
70
+ }
71
+
72
+ companion object {
73
+ fun fromJs(raw: String): ChainKey =
74
+ entries.find { it.name.equals(raw, ignoreCase = true) }
75
+ ?: throw ChainSigningException("Unsupported chain: $raw")
76
+ }
77
+ }
78
+
79
+ data class ChainSignResult(val signedTx: String, val meta: Map<String, Any>?)
80
+
81
+ object ChainSigner {
82
+ // SLIP-44 dedicates coin_type 1' to "testnet" for every coin — so a shared literal path
83
+ // would make Litecoin and Bitcoin Cash derive the *same* key (BIP32 derivation only depends
84
+ // on (seed, path, curve), and CoinType alone doesn't perturb it when the path and curve
85
+ // — secp256k1 for both — are identical). Disambiguate by using each coin's own SLIP-44
86
+ // index as the account (3rd) path component. `purpose` follows the usual BIP44/49/84
87
+ // convention (44' legacy, 84' native segwit) matching the address style each coin actually
88
+ // gets below.
89
+ private fun utxoTestnetPath(chain: ChainKey, purpose: Int): String =
90
+ "m/$purpose'/1'/${chain.coinType.slip44Id()}'/0/0"
91
+
92
+ private const val LITECOIN_TESTNET_HRP = "tltc"
93
+
94
+ // Bitcoin Cash testnet legacy P2PKH version byte (0x6F) — same value Bitcoin/Litecoin
95
+ // testnets use for their base58 legacy prefix. BCH has no bech32/cashaddr testnet support in
96
+ // wallet-core (cashaddr is a different, more involved encoding than bech32 — unlike
97
+ // Litecoin below, not reimplemented here), so it stays on this legacy fallback.
98
+ private const val BCH_TESTNET_P2PKH_PREFIX: Byte = 0x6F
99
+
100
+ /** Address for `chain`, honoring `isTestnet`.
101
+ *
102
+ * wallet-core's coin registry only carries a real testnet derivation for Bitcoin
103
+ * (`Derivation.BITCOINTESTNET`, native segwit — same "bc1"→"tb1" style shift as mainnet).
104
+ * Litecoin and Bitcoin Cash have no testnet entry at all (no CoinType, no Derivation):
105
+ * - Litecoin gets a hand-rolled native-segwit bech32 address (see Bech32.kt) — the same
106
+ * style as its own mainnet "ltc1..." address, just hrp "tltc" instead of "ltc". wallet-
107
+ * core's `SegwitAddress` can't do this itself (its HRP is a closed native enum with no
108
+ * "tltc" entry), so this reimplements the encode half of BIP-173 by hand.
109
+ * - Bitcoin Cash gets a legacy P2PKH address instead — a different *style* from its own
110
+ * mainnet cashaddr address (cashaddr testnet isn't implemented), but still a real,
111
+ * correctly-testnet-flagged one.
112
+ */
113
+ fun addressForChain(wallet: HDWallet, chain: ChainKey, isTestnet: Boolean): String {
114
+ if (!isTestnet) return wallet.getAddressForCoin(chain.coinType)
115
+ return when (chain) {
116
+ ChainKey.BITCOIN -> wallet.getAddressDerivation(CoinType.BITCOIN, Derivation.BITCOINTESTNET)
117
+ ChainKey.LITECOIN -> {
118
+ val pubKey = keyForChain(wallet, chain, isTestnet = true).getPublicKeySecp256k1(true)
119
+ val program = Hash.sha256RIPEMD(pubKey.data())
120
+ Bech32.encodeSegwitV0(LITECOIN_TESTNET_HRP, program)
121
+ }
122
+ ChainKey.BITCOINCASH -> {
123
+ val pubKey = keyForChain(wallet, chain, isTestnet = true).getPublicKeySecp256k1(true)
124
+ BitcoinAddress(pubKey, BCH_TESTNET_P2PKH_PREFIX).description()
125
+ }
126
+ // Everything else (EVM/Solana/Tron/Ton/Xrp) shares one address format across
127
+ // mainnet/testnet — only the RPC endpoint differs, which lives entirely in JS.
128
+ else -> wallet.getAddressForCoin(chain.coinType)
129
+ }
130
+ }
131
+
132
+ /** The signing key for `chain`, honoring `isTestnet` — must always derive the same key
133
+ * `addressForChain` used, or a UTXO signer builds a transaction that can't spend the
134
+ * wallet's own funds (wrong key ⇒ different scriptPubKey than what's actually sitting at
135
+ * the receive address it was given). */
136
+ private fun keyForChain(wallet: HDWallet, chain: ChainKey, isTestnet: Boolean): PrivateKey {
137
+ if (!isTestnet) return wallet.getKeyForCoin(chain.coinType)
138
+ return when (chain) {
139
+ ChainKey.BITCOIN -> wallet.getKeyDerivation(CoinType.BITCOIN, Derivation.BITCOINTESTNET)
140
+ ChainKey.LITECOIN -> wallet.getKey(CoinType.LITECOIN, utxoTestnetPath(chain, purpose = 84))
141
+ ChainKey.BITCOINCASH -> wallet.getKey(CoinType.BITCOINCASH, utxoTestnetPath(chain, purpose = 44))
142
+ else -> wallet.getKeyForCoin(chain.coinType)
143
+ }
144
+ }
145
+
146
+ fun sign(chain: ChainKey, wallet: HDWallet, unsignedTx: Map<String, Any>, isTestnet: Boolean): ChainSignResult = when (chain) {
147
+ ChainKey.ETHEREUM, ChainKey.BNB, ChainKey.POLYGON,
148
+ ChainKey.AVAX, ChainKey.BASE, ChainKey.ARBITRUM, ChainKey.OPTIMISM, ChainKey.SONIC ->
149
+ ChainSignResult(signEvm(wallet, chain.coinType, unsignedTx), null)
150
+ ChainKey.SOLANA -> ChainSignResult(signSolana(wallet, unsignedTx), null)
151
+ ChainKey.BITCOIN, ChainKey.DOGECOIN, ChainKey.LITECOIN -> ChainSignResult(signUtxo(wallet, chain, unsignedTx, isTestnet), null)
152
+ ChainKey.TRON -> ChainSignResult(signTron(wallet, unsignedTx), null)
153
+ ChainKey.XRP -> ChainSignResult(signXrp(wallet, unsignedTx), null)
154
+ ChainKey.TON -> signTon(wallet, unsignedTx)
155
+ ChainKey.BITCOINCASH -> ChainSignResult(signBch(wallet, unsignedTx), null)
156
+ ChainKey.COSMOS -> ChainSignResult(signCosmos(wallet, unsignedTx), null)
157
+ ChainKey.APTOS -> ChainSignResult(signAptos(wallet, unsignedTx), null)
158
+ ChainKey.TEZOS -> ChainSignResult(signTezos(wallet, unsignedTx), null)
159
+ }
160
+
161
+ // MARK: - EVM (ethereum / bnb / polygon)
162
+ // unsignedTx: { to, chainId, nonce, gasLimitHex, valueHex?, dataHex?,
163
+ // gasPriceHex? (legacy) | maxFeePerGasHex + maxPriorityFeePerGasHex (EIP-1559) }
164
+
165
+ private fun signEvm(wallet: HDWallet, coin: CoinType, unsignedTx: Map<String, Any>): String {
166
+ val privateKey = wallet.getKeyForCoin(coin)
167
+ val to = unsignedTx["to"] as? String ?: throw ChainSigningException("Missing to")
168
+ val nonce = (unsignedTx["nonce"] as? Number)?.toLong() ?: throw ChainSigningException("Missing nonce")
169
+ val gasLimHex = unsignedTx["gasLimitHex"] as? String ?: throw ChainSigningException("Missing gasLimitHex")
170
+ val chainId = (unsignedTx["chainId"] as? Number)?.toLong() ?: throw ChainSigningException("Missing chainId")
171
+ val valueHex = (unsignedTx["valueHex"] as? String)?.ifEmpty { "0" } ?: "0"
172
+ val dataHex = unsignedTx["dataHex"] as? String ?: ""
173
+
174
+ val input = Ethereum.SigningInput.newBuilder().apply {
175
+ this.chainId = BigInteger.valueOf(chainId).toMinimalByteString()
176
+ this.nonce = BigInteger.valueOf(nonce).toMinimalByteString()
177
+ this.gasLimit = BigInteger(gasLimHex, 16).toMinimalByteString()
178
+ this.toAddress = to
179
+ this.privateKey = ByteString.copyFrom(privateKey.data())
180
+
181
+ this.transaction = Ethereum.Transaction.newBuilder().apply {
182
+ this.transfer = Ethereum.Transaction.Transfer.newBuilder().apply {
183
+ this.amount = BigInteger(valueHex, 16).toMinimalByteString()
184
+ if (dataHex.isNotEmpty()) this.data = ByteString.copyFrom(dataHex.hexToBytes())
185
+ }.build()
186
+ }.build()
187
+
188
+ val gasPriceHex = unsignedTx["gasPriceHex"] as? String
189
+ if (gasPriceHex != null) {
190
+ this.gasPrice = BigInteger(gasPriceHex, 16).toMinimalByteString()
191
+ } else {
192
+ val mfHex = unsignedTx["maxFeePerGasHex"] as? String
193
+ val pfHex = unsignedTx["maxPriorityFeePerGasHex"] as? String
194
+ if (mfHex != null && pfHex != null) {
195
+ this.txMode = Ethereum.TransactionMode.Enveloped
196
+ this.maxFeePerGas = BigInteger(mfHex, 16).toMinimalByteString()
197
+ this.maxInclusionFeePerGas = BigInteger(pfHex, 16).toMinimalByteString()
198
+ }
199
+ }
200
+ }.build()
201
+
202
+ val output = AnySigner.sign(input, coin, Ethereum.SigningOutput.parser())
203
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
204
+ return "0x" + output.encoded.toByteArray().toHex()
205
+ }
206
+
207
+ // MARK: - Solana
208
+ // unsignedTx: { unsignedTxBase64: string } — a fully-built, base64-serialized unsigned
209
+ // @solana/web3.js Transaction (native transfer or SPL-token transfer) from
210
+ // prepareSelfCustodyUnsignedTx's SOL branch, already carrying a recentBlockhash/feePayer.
211
+ // wallet-core has no "sign these raw bytes as-is" entry point for Solana — RawMessage is a
212
+ // structured legacy/v0 message, not an opaque blob. `updateBlockhashAndSign` is TW's own
213
+ // helper for signing an already-built web3.js transaction: it takes the base64 tx, a
214
+ // recent blockhash, and a set of private keys, and re-serializes+signs. We don't have a
215
+ // fresher blockhash than the one already embedded, so decode the tx to read it back out
216
+ // (via TransactionDecoder) and hand it straight back in — a no-op "update" that still goes
217
+ // through the sanctioned signing path.
218
+ private fun signSolana(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
219
+ val privateKey = wallet.getKeyForCoin(CoinType.SOLANA)
220
+ val unsignedTxBase64 = unsignedTx["unsignedTxBase64"] as? String
221
+ ?: throw ChainSigningException("Missing unsignedTxBase64")
222
+ val txBytes = android.util.Base64.decode(unsignedTxBase64, android.util.Base64.NO_WRAP)
223
+
224
+ val decoded = Solana.DecodingTransactionOutput.parseFrom(TransactionDecoder.decode(CoinType.SOLANA, txBytes))
225
+ if (decoded.error != Common.SigningError.OK) {
226
+ throw ChainSigningException("Failed to decode Solana tx: ${decoded.errorMessage}")
227
+ }
228
+ val recentBlockhash = decoded.transaction.legacy.recentBlockhash
229
+
230
+ val privateKeys = DataVector()
231
+ privateKeys.add(privateKey.data())
232
+
233
+ val output = Solana.SigningOutput.parseFrom(
234
+ SolanaTransaction.updateBlockhashAndSign(unsignedTxBase64, recentBlockhash, privateKeys)
235
+ )
236
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
237
+ return output.encoded
238
+ }
239
+
240
+ // MARK: - BCH (UTXO-based, replay-protected)
241
+ // unsignedTx: { unsignedDescriptorJson: string }
242
+ // descriptor (from wallet-broadcast's prepareBchTransaction):
243
+ // { inputs: [{ txid, vout, satoshis, scriptPubKeyHex }], toAddress, sendAmountSats,
244
+ // changeAddress?, changeSats? }
245
+ // BCH uses SIGHASH_ALL | SIGHASH_FORK_ID (0x41) for replay protection.
246
+ private fun signBch(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
247
+ val descriptorJson = unsignedTx["unsignedDescriptorJson"] as? String
248
+ ?: throw ChainSigningException("Missing unsignedDescriptorJson for BCH")
249
+
250
+ val descriptor = JSONObject(descriptorJson)
251
+ val toAddress = descriptor.getString("toAddress")
252
+ val sendAmountSats = descriptor.getLong("sendAmountSats")
253
+ val changeAddress = if (descriptor.has("changeAddress")) descriptor.getString("changeAddress") else null
254
+ val inputsJson = descriptor.getJSONArray("inputs")
255
+
256
+ val privateKey = wallet.getKeyForCoin(CoinType.BITCOINCASH)
257
+
258
+ val utxos = (0 until inputsJson.length()).map { i ->
259
+ val entry = inputsJson.getJSONObject(i)
260
+ val txIdHex = entry.getString("txid")
261
+ val vout = entry.getInt("vout")
262
+ val satoshis = entry.getLong("satoshis")
263
+ val scriptHex = entry.getString("scriptPubKeyHex")
264
+ val txIdBytes = txIdHex.hexToBytes().reversedArray()
265
+
266
+ Bitcoin.UnspentTransaction.newBuilder().apply {
267
+ this.outPoint = Bitcoin.OutPoint.newBuilder().apply {
268
+ this.hash = ByteString.copyFrom(txIdBytes)
269
+ this.index = vout
270
+ }.build()
271
+ this.amount = satoshis
272
+ this.script = ByteString.copyFrom(scriptHex.hexToBytes())
273
+ }.build()
274
+ }
275
+
276
+ val input = Bitcoin.SigningInput.newBuilder().apply {
277
+ this.hashType = 0x41 // SIGHASH_ALL | SIGHASH_FORK_ID (BCH replay protection)
278
+ this.amount = sendAmountSats
279
+ this.byteFee = 1 // BCH fees are minimal; 1 sat/byte
280
+ this.toAddress = toAddress
281
+ if (changeAddress != null) this.changeAddress = changeAddress
282
+ this.useMaxAmount = false
283
+ this.coinType = CoinType.BITCOINCASH.value()
284
+ this.addPrivateKey(ByteString.copyFrom(privateKey.data()))
285
+ this.addAllUtxo(utxos)
286
+ }.build()
287
+
288
+ val output = AnySigner.sign(input, CoinType.BITCOINCASH, Bitcoin.SigningOutput.parser())
289
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("BCH signing failed: ${output.errorMessage}")
290
+ return output.encoded.toByteArray().toHex()
291
+ }
292
+
293
+ // MARK: - BTC / LTC (UTXO-based)
294
+ // unsignedTx (from wallet-broadcast's additive prepareBtcUtxoSet/prepareLtcUtxoSet):
295
+ // { toAddress, changeAddress, sendAmountSats, changeAmountSats, satsPerByte,
296
+ // inputs: [{ txIdHex, vout, amountSats, scriptPubKeyHex }] }
297
+ @Suppress("UNCHECKED_CAST")
298
+ private fun signUtxo(wallet: HDWallet, chain: ChainKey, unsignedTx: Map<String, Any>, isTestnet: Boolean): String {
299
+ val coin = chain.coinType
300
+ val privateKey = keyForChain(wallet, chain, isTestnet)
301
+ val toAddress = unsignedTx["toAddress"] as? String ?: throw ChainSigningException("Missing toAddress")
302
+ val changeAddress = unsignedTx["changeAddress"] as? String ?: throw ChainSigningException("Missing changeAddress")
303
+ val sendAmountSats = (unsignedTx["sendAmountSats"] as? String)?.toLong() ?: throw ChainSigningException("Missing sendAmountSats")
304
+ val satsPerByte = (unsignedTx["satsPerByte"] as? Number)?.toLong() ?: throw ChainSigningException("Missing satsPerByte")
305
+ val inputs = unsignedTx["inputs"] as? List<Map<String, Any>> ?: throw ChainSigningException("Missing inputs")
306
+
307
+ debugLog { "signUtxo: parsing ${inputs.size} UTXOs" }
308
+ val utxos = inputs.map { entry ->
309
+ val txIdHex = entry["txIdHex"] as? String ?: throw ChainSigningException("Invalid UTXO entry: missing txIdHex, keys=${entry.keys}")
310
+ val vout = (entry["vout"] as? Number)?.toInt() ?: throw ChainSigningException("Invalid UTXO entry: missing vout")
311
+ val amount = (entry["amountSats"] as? String)?.toLong() ?: throw ChainSigningException("Invalid UTXO entry: missing amountSats, type=${entry["amountSats"]?.javaClass?.name}")
312
+ val scriptHex = entry["scriptPubKeyHex"] as? String ?: throw ChainSigningException("Invalid UTXO entry: missing scriptPubKeyHex")
313
+ debugLog { "signUtxo: UTXO txid=$txIdHex vout=$vout amount=$amount" }
314
+
315
+ // On-chain/explorer txid hex is displayed big-endian; wallet-core's OutPoint.hash wants
316
+ // the reversed (little-endian, internal wire-format) byte order.
317
+ val txIdBytes = txIdHex.hexToBytes().reversedArray()
318
+
319
+ Bitcoin.UnspentTransaction.newBuilder().apply {
320
+ this.outPoint = Bitcoin.OutPoint.newBuilder().apply {
321
+ this.hash = ByteString.copyFrom(txIdBytes)
322
+ this.index = vout
323
+ }.build()
324
+ this.amount = amount
325
+ this.script = ByteString.copyFrom(scriptHex.hexToBytes())
326
+ }.build()
327
+ }
328
+
329
+ debugLog { "signUtxo: building SigningInput toAddress=$toAddress sats=$sendAmountSats fee=$satsPerByte" }
330
+ val input = Bitcoin.SigningInput.newBuilder().apply {
331
+ this.hashType = 1 // SIGHASH_ALL — stable Bitcoin protocol constant, not a wallet-core-specific value
332
+ this.amount = sendAmountSats
333
+ this.byteFee = satsPerByte
334
+ this.toAddress = toAddress
335
+ this.changeAddress = changeAddress
336
+ this.useMaxAmount = false
337
+ this.coinType = coin.value()
338
+ this.addPrivateKey(ByteString.copyFrom(privateKey.data()))
339
+ this.addAllUtxo(utxos)
340
+ }.build()
341
+
342
+ debugLog { "signUtxo: calling AnySigner.sign coin=${coin.name}" }
343
+ val output = AnySigner.sign(input, coin, Bitcoin.SigningOutput.parser())
344
+ debugLog { "signUtxo: AnySigner.sign done error=${output.error} msg=${output.errorMessage}" }
345
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
346
+ return output.encoded.toByteArray().toHex()
347
+ }
348
+
349
+ // MARK: - TRX
350
+ // unsignedTx: the raw TronGrid-shaped unsigned tx object from prepareSelfCustodyUnsignedTx's
351
+ // TRX branch (TronWeb's transactionBuilder output — raw_data, raw_data_hex, txID). Building a
352
+ // Tron.Transaction proto from scratch would need the *full* source BlockHeader (parent hash,
353
+ // tx trie root, witness address, version): wallet-core hashes that header itself to derive
354
+ // ref_block_bytes/ref_block_hash, and TronGrid only ever hands us those two derived fields
355
+ // pre-computed, not the header they came from — so there's no way to reconstruct one that
356
+ // rehashes to the same values. Tron.SigningInput's `txId` field exists for exactly this case
357
+ // (see its proto doc: "direct sign in Tron, we just have to sign the txId returned by the
358
+ // DApp json payload") — it signs the given digest as-is and skips transaction rebuilding
359
+ // entirely, which also means TRC20 `triggerSmartContract` payloads are covered for free, not
360
+ // just plain transfers. wallet-core's direct-sign path only returns the raw signature (no
361
+ // `json`), so the signed tx is assembled here the same way TronWeb does: original tx + a
362
+ // `signature` array — the shape broadcastTrxTransaction's tronWeb.trx.sendRawTransaction expects.
363
+ private fun signTron(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
364
+ val privateKey = wallet.getKeyForCoin(CoinType.TRON)
365
+ val txId = unsignedTx["txID"] as? String ?: throw ChainSigningException("Missing txID")
366
+
367
+ val input = Tron.SigningInput.newBuilder().apply {
368
+ this.txId = txId
369
+ this.privateKey = ByteString.copyFrom(privateKey.data())
370
+ }.build()
371
+
372
+ val output = AnySigner.sign(input, CoinType.TRON, Tron.SigningOutput.parser())
373
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
374
+
375
+ val signatureHex = output.signature.toByteArray().toHex()
376
+ val signedTx = JSONObject(unsignedTx).put("signature", org.json.JSONArray().put(signatureHex))
377
+ return signedTx.toString()
378
+ }
379
+
380
+ // MARK: - XRP
381
+ // unsignedTx: xrpl.js `Payment` object (Account, Destination, Amount (drops, string),
382
+ // Fee (drops, string), Sequence, LastLedgerSequence, DestinationTag?).
383
+ private fun signXrp(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
384
+ val privateKey = wallet.getKeyForCoin(CoinType.XRP)
385
+ val account = unsignedTx["Account"] as? String ?: throw ChainSigningException("Missing Account")
386
+ val destination = unsignedTx["Destination"] as? String ?: throw ChainSigningException("Missing Destination")
387
+ val amountDrops = unsignedTx["Amount"] as? String ?: throw ChainSigningException("Missing Amount")
388
+ val feeDrops = unsignedTx["Fee"] as? String ?: throw ChainSigningException("Missing Fee")
389
+ val sequence = (unsignedTx["Sequence"] as? Number)?.toInt() ?: throw ChainSigningException("Missing Sequence")
390
+ val lastLedgerSequence = (unsignedTx["LastLedgerSequence"] as? Number)?.toInt()
391
+ val destinationTag = parseXrpDestinationTag((unsignedTx["DestinationTag"] as? Number)?.toLong())
392
+
393
+ val input = Ripple.SigningInput.newBuilder().apply {
394
+ this.privateKey = ByteString.copyFrom(privateKey.data())
395
+ this.account = account
396
+ this.fee = parseXrpFeeDrops(feeDrops)
397
+ this.sequence = sequence
398
+ lastLedgerSequence?.let { this.lastLedgerSequence = it }
399
+ this.opPayment = Ripple.OperationPayment.newBuilder().apply {
400
+ this.amount = parseXrpAmountDrops(amountDrops)
401
+ this.destination = destination
402
+ destinationTag?.let { this.destinationTag = it }
403
+ }.build()
404
+ }.build()
405
+
406
+ val output = AnySigner.sign(input, CoinType.XRP, Ripple.SigningOutput.parser())
407
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
408
+ return output.encoded.toByteArray().toHex()
409
+ }
410
+
411
+ // MARK: - TON
412
+ // unsignedTx: { toAddress, amount, seqno, memoId? }.
413
+ // NOTE(verify-on-device): confirm `amount` units (nanoton vs TON) and that wallet_version
414
+ // V4R2 matches the address format the current @ton/* implementation derives.
415
+ // expireAt isn't supplied by prepareSelfCustodyUnsignedTx's TON branch (just
416
+ // {toAddress,amount,seqno,memoId}); default to a 10-minute signing window, matching the
417
+ // extendExpiration(tx, 600) buffer TRX's prepare step already uses.
418
+ private fun signTon(wallet: HDWallet, unsignedTx: Map<String, Any>): ChainSignResult {
419
+ val privateKey = wallet.getKeyForCoin(CoinType.TON)
420
+ val toAddress = unsignedTx["toAddress"] as? String ?: throw ChainSigningException("Missing toAddress")
421
+ val amountStr = unsignedTx["amount"] as? String ?: throw ChainSigningException("Missing amount")
422
+ val amount = parseTonNanotons(amountStr)
423
+ val seqno = (unsignedTx["seqno"] as? Number)?.toInt() ?: throw ChainSigningException("Missing seqno")
424
+ val memoId = unsignedTx["memoId"] as? String
425
+ val expireAt = (System.currentTimeMillis() / 1000L + 600L).toInt()
426
+
427
+ val transfer = TheOpenNetwork.Transfer.newBuilder().apply {
428
+ this.dest = toAddress
429
+ this.amount = amount
430
+ this.mode = TheOpenNetwork.SendMode.PAY_FEES_SEPARATELY_VALUE or TheOpenNetwork.SendMode.IGNORE_ACTION_PHASE_ERRORS_VALUE
431
+ this.bounceable = true
432
+ memoId?.let { this.comment = it }
433
+ }.build()
434
+
435
+ val input = TheOpenNetwork.SigningInput.newBuilder().apply {
436
+ this.privateKey = ByteString.copyFrom(privateKey.data())
437
+ this.walletVersion = TheOpenNetwork.WalletVersion.WALLET_V4_R2
438
+ this.sequenceNumber = seqno
439
+ this.expireAt = expireAt
440
+ this.addMessages(transfer)
441
+ }.build()
442
+
443
+ val output = AnySigner.sign(input, CoinType.TON, TheOpenNetwork.SigningOutput.parser())
444
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
445
+ return ChainSignResult(output.encoded, mapOf("txHash" to output.hash.toByteArray().toHex()))
446
+ }
447
+
448
+ // MARK: - Cosmos (ATOM)
449
+ // unsignedTx: { accountNumber, sequence, chainId, feeAmount, gas, memo, fromAddress, toAddress,
450
+ // amount (uatom, decimal string), denom }
451
+ // Returns output.serialized — ready-to-broadcast JSON for the Cosmos LCD.
452
+ private fun signCosmos(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
453
+ val privateKey = wallet.getKeyForCoin(CoinType.COSMOS)
454
+ val fromAddress = unsignedTx["fromAddress"] as? String ?: throw ChainSigningException("Missing fromAddress")
455
+ val toAddress = unsignedTx["toAddress"] as? String ?: throw ChainSigningException("Missing toAddress")
456
+ val amountStr = unsignedTx["amount"] as? String ?: throw ChainSigningException("Missing amount")
457
+ val feeAmountStr = unsignedTx["feeAmount"] as? String ?: throw ChainSigningException("Missing feeAmount")
458
+ val denom = unsignedTx["denom"] as? String ?: throw ChainSigningException("Missing denom")
459
+ val chainId = unsignedTx["chainId"] as? String ?: throw ChainSigningException("Missing chainId")
460
+ val accountNumber = (unsignedTx["accountNumber"] as? Number)?.toLong() ?: throw ChainSigningException("Missing accountNumber")
461
+ val sequence = (unsignedTx["sequence"] as? Number)?.toLong() ?: throw ChainSigningException("Missing sequence")
462
+ val gas = (unsignedTx["gas"] as? Number)?.toLong() ?: throw ChainSigningException("Missing gas")
463
+ val memo = unsignedTx["memo"] as? String ?: ""
464
+
465
+ val sendAmount = Cosmos.Amount.newBuilder()
466
+ .setAmount(amountStr)
467
+ .setDenom(denom)
468
+ .build()
469
+
470
+ val sendMsg = Cosmos.Message.Send.newBuilder()
471
+ .setFromAddress(fromAddress)
472
+ .setToAddress(toAddress)
473
+ .addAmounts(sendAmount)
474
+ .build()
475
+
476
+ val message = Cosmos.Message.newBuilder()
477
+ .setSendCoinsMessage(sendMsg)
478
+ .build()
479
+
480
+ val feeAmount = Cosmos.Amount.newBuilder()
481
+ .setAmount(feeAmountStr)
482
+ .setDenom(denom)
483
+ .build()
484
+
485
+ val fee = Cosmos.Fee.newBuilder()
486
+ .setGas(gas)
487
+ .addAmounts(feeAmount)
488
+ .build()
489
+
490
+ val input = Cosmos.SigningInput.newBuilder().apply {
491
+ this.signingMode = Cosmos.SigningMode.Protobuf
492
+ this.accountNumber = accountNumber
493
+ this.chainId = chainId
494
+ this.sequence = sequence
495
+ this.memo = memo
496
+ this.fee = fee
497
+ this.addMessages(message)
498
+ this.privateKey = ByteString.copyFrom(privateKey.data())
499
+ this.mode = Cosmos.BroadcastMode.SYNC
500
+ }.build()
501
+
502
+ val output = AnySigner.sign(input, CoinType.COSMOS, Cosmos.SigningOutput.parser())
503
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Cosmos signing failed: ${output.errorMessage}")
504
+ return output.serialized
505
+ }
506
+
507
+ // MARK: - Aptos (APT)
508
+ // unsignedTx: { sender, sequenceNumber, maxGasAmount, gasUnitPrice, expirationTimestampSecs,
509
+ // chainId, toAddress, amount (octas, decimal string) }
510
+ // Returns output.json — the signed JSON body posted directly to the Aptos REST API.
511
+ private fun signAptos(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
512
+ val privateKey = wallet.getKeyForCoin(CoinType.APTOS)
513
+ val sender = unsignedTx["sender"] as? String ?: throw ChainSigningException("Missing sender")
514
+ val toAddress = unsignedTx["toAddress"] as? String ?: throw ChainSigningException("Missing toAddress")
515
+ val amountStr = unsignedTx["amount"] as? String ?: throw ChainSigningException("Missing amount")
516
+ val sequenceNumber = (unsignedTx["sequenceNumber"] as? Number)?.toLong() ?: throw ChainSigningException("Missing sequenceNumber")
517
+ val maxGasAmount = (unsignedTx["maxGasAmount"] as? Number)?.toLong() ?: throw ChainSigningException("Missing maxGasAmount")
518
+ val gasUnitPrice = (unsignedTx["gasUnitPrice"] as? Number)?.toLong() ?: throw ChainSigningException("Missing gasUnitPrice")
519
+ val expirationTimestampSecs = (unsignedTx["expirationTimestampSecs"] as? Number)?.toLong() ?: throw ChainSigningException("Missing expirationTimestampSecs")
520
+ val chainId = (unsignedTx["chainId"] as? Number)?.toInt() ?: throw ChainSigningException("Missing chainId")
521
+ val amountOctas = amountStr.toLongOrNull() ?: throw ChainSigningException("Invalid Aptos amount: $amountStr")
522
+
523
+ val transfer = Aptos.TransferMessage.newBuilder()
524
+ .setTo(toAddress)
525
+ .setAmount(amountOctas)
526
+ .build()
527
+
528
+ val input = Aptos.SigningInput.newBuilder()
529
+ .setSender(sender)
530
+ .setSequenceNumber(sequenceNumber)
531
+ .setMaxGasAmount(maxGasAmount)
532
+ .setGasUnitPrice(gasUnitPrice)
533
+ .setExpirationTimestampSecs(expirationTimestampSecs)
534
+ .setChainId(chainId)
535
+ .setPrivateKey(ByteString.copyFrom(privateKey.data()))
536
+ .setTransfer(transfer)
537
+ .build()
538
+
539
+ val output = AnySigner.sign(input, CoinType.APTOS, Aptos.SigningOutput.parser())
540
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Aptos signing failed: ${output.errorMessage}")
541
+ return output.json
542
+ }
543
+
544
+ // MARK: - Tezos (XTZ)
545
+ // unsignedTx: { branch, fromAddress, toAddress, counter, amount (mutez), fee (mutez),
546
+ // gasLimit, storageLimit, needsReveal }
547
+ // Returns output.encoded hex — broadcast via POST /injection/operation as JSON-encoded string.
548
+ private fun signTezos(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
549
+ val privateKey = wallet.getKeyForCoin(CoinType.TEZOS)
550
+ val branch = unsignedTx["branch"] as? String ?: throw ChainSigningException("Missing branch")
551
+ val fromAddress = unsignedTx["fromAddress"] as? String ?: throw ChainSigningException("Missing fromAddress")
552
+ val toAddress = unsignedTx["toAddress"] as? String ?: throw ChainSigningException("Missing toAddress")
553
+ val counter = (unsignedTx["counter"] as? Number)?.toLong() ?: throw ChainSigningException("Missing counter")
554
+ val amount = (unsignedTx["amount"] as? Number)?.toLong() ?: throw ChainSigningException("Missing amount")
555
+ val fee = (unsignedTx["fee"] as? Number)?.toLong() ?: throw ChainSigningException("Missing fee")
556
+ val gasLimit = (unsignedTx["gasLimit"] as? Number)?.toLong() ?: throw ChainSigningException("Missing gasLimit")
557
+ val storageLimit = (unsignedTx["storageLimit"] as? Number)?.toLong() ?: throw ChainSigningException("Missing storageLimit")
558
+ val needsReveal = unsignedTx["needsReveal"] as? Boolean ?: false
559
+
560
+ val operations = mutableListOf<Tezos.Operation>()
561
+
562
+ if (needsReveal) {
563
+ val pubKey = privateKey.getPublicKeyEd25519()
564
+ val revealData = Tezos.RevealOperationData.newBuilder()
565
+ .setPublicKey(ByteString.copyFrom(pubKey.data()))
566
+ .build()
567
+ operations.add(
568
+ Tezos.Operation.newBuilder()
569
+ .setSource(fromAddress)
570
+ .setCounter(counter - 1)
571
+ .setFee(1420L)
572
+ .setGasLimit(10600L)
573
+ .setStorageLimit(0L)
574
+ .setKind(Tezos.Operation.OperationKind.REVEAL)
575
+ .setRevealOperationData(revealData)
576
+ .build()
577
+ )
578
+ }
579
+
580
+ val txData = Tezos.TransactionOperationData.newBuilder()
581
+ .setDestination(toAddress)
582
+ .setAmount(amount)
583
+ .build()
584
+
585
+ operations.add(
586
+ Tezos.Operation.newBuilder()
587
+ .setSource(fromAddress)
588
+ .setCounter(counter)
589
+ .setFee(fee)
590
+ .setGasLimit(gasLimit)
591
+ .setStorageLimit(storageLimit)
592
+ .setKind(Tezos.Operation.OperationKind.TRANSACTION)
593
+ .setTransactionOperationData(txData)
594
+ .build()
595
+ )
596
+
597
+ val opList = Tezos.OperationList.newBuilder()
598
+ .setBranch(branch)
599
+ .addAllOperations(operations)
600
+ .build()
601
+
602
+ val input = Tezos.SigningInput.newBuilder()
603
+ .setOperationList(opList)
604
+ .setPrivateKey(ByteString.copyFrom(privateKey.data()))
605
+ .build()
606
+
607
+ val output = AnySigner.sign(input, CoinType.TEZOS, Tezos.SigningOutput.parser())
608
+ if (output.error != Common.SigningError.OK) throw ChainSigningException("Tezos signing failed: ${output.errorMessage}")
609
+ return output.encoded.toByteArray().toHex()
610
+ }
611
+ }
612
+
613
+ // Transaction summary helpers (used by ChainberryTrustWalletCoreModule for native confirmation UI)
614
+
615
+ internal fun ChainSigner.buildSummary(chain: ChainKey, unsignedTx: Map<String, Any>): String {
616
+ val lines = mutableListOf("Network: ${chain.name}")
617
+ when (chain) {
618
+ ChainKey.ETHEREUM, ChainKey.BNB, ChainKey.POLYGON,
619
+ ChainKey.AVAX, ChainKey.BASE, ChainKey.ARBITRUM, ChainKey.OPTIMISM, ChainKey.SONIC -> {
620
+ (unsignedTx["to"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
621
+ val valueWei = txHexToDouble((unsignedTx["valueHex"] as? String) ?: "0")
622
+ lines += "Amount: ${fmtAmt(valueWei / 1e18)} ${chain.symbol}"
623
+ val gasLimit = txHexToDouble((unsignedTx["gasLimitHex"] as? String) ?: "0")
624
+ val gasPrice = txHexToDouble(
625
+ (unsignedTx["gasPriceHex"] as? String) ?: (unsignedTx["maxFeePerGasHex"] as? String) ?: "0"
626
+ )
627
+ val feeWei = gasLimit * gasPrice
628
+ if (feeWei > 0) lines += "Max fee: ${fmtAmt(feeWei / 1e18)} ${chain.symbol}"
629
+ (unsignedTx["chainId"] as? Number)?.let { lines += "Chain ID: ${it.toInt()}" }
630
+ (unsignedTx["nonce"] as? Number)?.let { lines += "Nonce: ${it.toInt()}" }
631
+ val dataHex = (unsignedTx["dataHex"] as? String) ?: ""
632
+ val stripped = dataHex.removePrefix("0x")
633
+ if (stripped.isNotEmpty() && stripped != "0") {
634
+ val sel = stripped.take(8).lowercase()
635
+ if (sel == "a9059cbb" && stripped.length >= 136) {
636
+ // transfer(address recipient, uint256 amount)
637
+ val recipient = "0x" + stripped.drop(32).take(40)
638
+ val amountHex = stripped.drop(72).take(64).trimStart('0').ifEmpty { "0" }
639
+ lines += "Token transfer to: ${fmtAddr(recipient)}"
640
+ lines += "Token amount (raw units): 0x$amountHex"
641
+ } else if (sel == "23b872dd" && stripped.length >= 200) {
642
+ // transferFrom(address from, address to, uint256 amount)
643
+ val to = "0x" + stripped.drop(96).take(40)
644
+ val amountHex = stripped.drop(136).take(64).trimStart('0').ifEmpty { "0" }
645
+ lines += "Token transfer to: ${fmtAddr(to)}"
646
+ lines += "Token amount (raw units): 0x$amountHex"
647
+ } else {
648
+ lines += "Contract data: ${stripped.length / 2} bytes — review carefully"
649
+ }
650
+ }
651
+ }
652
+ ChainKey.BITCOIN, ChainKey.DOGECOIN, ChainKey.LITECOIN -> {
653
+ (unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
654
+ val sendSats = (unsignedTx["sendAmountSats"] as? String)?.toLongOrNull() ?: 0L
655
+ if (sendSats > 0) lines += "Amount: ${fmtAmt(sendSats.toDouble() / 1e8)} ${chain.symbol}"
656
+ (unsignedTx["changeAddress"] as? String)?.let { lines += "Change to: ${fmtAddr(it)}" }
657
+ (unsignedTx["satsPerByte"] as? Number)?.let { lines += "Fee rate: ${it.toInt()} sat/vB" }
658
+ @Suppress("UNCHECKED_CAST")
659
+ val inputTotal = (unsignedTx["inputs"] as? List<Map<String, Any>>)
660
+ ?.mapNotNull { (it["amountSats"] as? String)?.toLongOrNull() }
661
+ ?.fold(0L, Long::plus) ?: 0L
662
+ val changeSats = (unsignedTx["changeAmountSats"] as? String)?.toLongOrNull() ?: 0L
663
+ val totalFee = inputTotal - sendSats - changeSats
664
+ if (totalFee > 0) lines += "Total fee: ${fmtAmt(totalFee.toDouble() / 1e8)} ${chain.symbol}"
665
+ }
666
+ ChainKey.XRP -> {
667
+ (unsignedTx["Destination"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
668
+ (unsignedTx["Amount"] as? String)?.toLongOrNull()?.let {
669
+ lines += "Amount: ${fmtAmt(it.toDouble() / 1_000_000.0)} XRP"
670
+ }
671
+ (unsignedTx["Fee"] as? String)?.toLongOrNull()?.let {
672
+ lines += "Fee: ${fmtAmt(it.toDouble() / 1_000_000.0)} XRP"
673
+ }
674
+ unsignedTx["DestinationTag"]?.let { lines += "Tag: $it" }
675
+ }
676
+ ChainKey.TON -> {
677
+ (unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
678
+ (unsignedTx["amount"] as? String)?.toULongOrNull()?.let {
679
+ lines += "Amount: ${fmtAmt(it.toDouble() / 1e9)} TON"
680
+ }
681
+ val memoTon = (unsignedTx["memoId"] as? String)?.takeIf { it.isNotEmpty() }
682
+ memoTon?.let { lines += "Memo: $it" }
683
+ lines += "Fee: ${if (memoTon != null) "~0.006" else "~0.005"} TON (estimate)"
684
+ }
685
+ ChainKey.TRON -> {
686
+ @Suppress("UNCHECKED_CAST")
687
+ val firstContract = (unsignedTx["raw_data"] as? Map<String, Any>)
688
+ ?.let { (it["contract"] as? List<Map<String, Any>>)?.firstOrNull() }
689
+ firstContract?.let { contract ->
690
+ val type_ = contract["type"] as? String ?: ""
691
+ val value = (contract["parameter"] as? Map<String, Any>)
692
+ ?.let { it["value"] as? Map<String, Any> }
693
+ when (type_) {
694
+ "TransferContract" -> value?.let { v ->
695
+ (v["to_address"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
696
+ (v["amount"] as? Number)?.let { lines += "Amount: ${fmtAmt(it.toDouble() / 1e6)} TRX" }
697
+ }
698
+ "TriggerSmartContract" -> value?.let { v ->
699
+ (v["contract_address"] as? String)?.let { lines += "Token contract: ${fmtAddr(it)}" }
700
+ val dataHex = (v["data"] as? String) ?: ""
701
+ val stripped = dataHex.removePrefix("0x")
702
+ val sel = stripped.take(8).lowercase()
703
+ if (sel == "a9059cbb" && stripped.length >= 136) {
704
+ // TRC-20 transfer(address, uint256) — ABI encoding identical to EVM
705
+ val recipientHex = "0x" + stripped.drop(32).take(40)
706
+ val amountHex = stripped.drop(72).take(64).trimStart('0').ifEmpty { "0" }
707
+ lines += "TRC-20 to: ${fmtAddr(recipientHex)}"
708
+ lines += "Token amount (raw units): 0x$amountHex"
709
+ } else {
710
+ lines += "Contract call: ${stripped.length / 2} bytes — review carefully"
711
+ }
712
+ }
713
+ else -> if (type_.isNotEmpty()) lines += "Contract type: $type_ — review carefully"
714
+ }
715
+ }
716
+ (unsignedTx["txID"] as? String)?.let { txId ->
717
+ val rawHex = unsignedTx["raw_data_hex"] as? String
718
+ if (rawHex != null) {
719
+ val computed = java.security.MessageDigest.getInstance("SHA-256")
720
+ .digest(rawHex.hexToBytes()).toHex()
721
+ if (computed.lowercase() != txId.lowercase())
722
+ throw ChainSigningException("TRX txID does not match SHA256(raw_data_hex) — signing refused")
723
+ lines += "TxID verified ✓"
724
+ } else {
725
+ lines += "TxID: ${txId.take(16)}… (raw_data_hex absent — unverified)"
726
+ }
727
+ }
728
+ }
729
+ ChainKey.SOLANA -> {
730
+ val info = (unsignedTx["unsignedTxBase64"] as? String)?.let { decodeSolanaForSummary(it) }
731
+ ?: throw ChainSigningException(
732
+ "Cannot decode Solana transaction — signing refused to prevent blind signing"
733
+ )
734
+ if (info.isSplTransfer) {
735
+ info.splDest?.let { lines += "SPL Token to: ${fmtAddr(it)}" }
736
+ info.splAmount?.let { lines += "SPL Token amount (raw): $it" }
737
+ } else {
738
+ info.to?.let { lines += "To: ${fmtAddr(it)}" }
739
+ info.lamports?.let { lines += "Amount: ${fmtAmt(it.toDouble() / 1e9)} SOL" }
740
+ if (!info.isTransfer) lines += "Non-transfer instruction — review carefully"
741
+ }
742
+ }
743
+ ChainKey.SOLANA -> lines += "(Solana — details verified by the network)"
744
+ ChainKey.BITCOINCASH -> {
745
+ try {
746
+ val descriptor = JSONObject((unsignedTx["unsignedDescriptorJson"] as? String) ?: "")
747
+ descriptor.optString("toAddress").takeIf { it.isNotEmpty() }?.let { lines += "To: ${fmtAddr(it)}" }
748
+ val sats = descriptor.optLong("sendAmountSats", -1L)
749
+ if (sats >= 0) lines += "Amount: ${fmtAmt(sats.toDouble() / 1e8)} BCH"
750
+ } catch (_: Exception) {}
751
+ }
752
+ ChainKey.COSMOS -> {
753
+ (unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
754
+ (unsignedTx["amount"] as? String)?.toLongOrNull()?.let {
755
+ lines += "Amount: ${fmtAmt(it.toDouble() / 1_000_000.0)} ATOM"
756
+ }
757
+ (unsignedTx["feeAmount"] as? String)?.toLongOrNull()?.let {
758
+ lines += "Fee: ${fmtAmt(it.toDouble() / 1_000_000.0)} ATOM"
759
+ }
760
+ }
761
+ ChainKey.APTOS -> {
762
+ (unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
763
+ (unsignedTx["amount"] as? String)?.toLongOrNull()?.let {
764
+ lines += "Amount: ${fmtAmt(it.toDouble() / 1e8)} APT"
765
+ }
766
+ val maxGas = (unsignedTx["maxGasAmount"] as? Number)?.toLong()
767
+ val gasPrice = (unsignedTx["gasUnitPrice"] as? Number)?.toLong()
768
+ if (maxGas != null && gasPrice != null) {
769
+ lines += "Max fee: ${fmtAmt((maxGas * gasPrice).toDouble() / 1e8)} APT"
770
+ }
771
+ }
772
+ ChainKey.TEZOS -> {
773
+ (unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
774
+ (unsignedTx["amount"] as? Number)?.toLong()?.let {
775
+ lines += "Amount: ${fmtAmt(it.toDouble() / 1_000_000.0)} XTZ"
776
+ }
777
+ (unsignedTx["fee"] as? Number)?.toLong()?.let {
778
+ lines += "Fee: ${fmtAmt(it.toDouble() / 1_000_000.0)} XTZ"
779
+ }
780
+ if (unsignedTx["needsReveal"] == true) lines += "(includes reveal operation)"
781
+ }
782
+ }
783
+ return lines.joinToString("\n")
784
+ }
785
+
786
+ private data class SolanaSummary(
787
+ val to: String?,
788
+ val lamports: ULong?,
789
+ val isTransfer: Boolean,
790
+ val splDest: String?,
791
+ val splAmount: ULong?,
792
+ val isSplTransfer: Boolean
793
+ )
794
+
795
+ private fun decodeSolanaForSummary(b64: String): SolanaSummary? {
796
+ return try {
797
+ val txBytes = android.util.Base64.decode(b64, android.util.Base64.DEFAULT)
798
+ val decoded = Solana.DecodingTransactionOutput.parseFrom(TransactionDecoder.decode(CoinType.SOLANA, txBytes))
799
+ if (decoded.error != Common.SigningError.OK) return null
800
+ val accounts = decoded.transaction.legacy.accountKeysList
801
+ val systemProgram = "11111111111111111111111111111111"
802
+ val splPrograms = setOf(
803
+ "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
804
+ "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
805
+ )
806
+
807
+ var systemIx: Solana.RawMessage.Instruction? = null
808
+ var splIx: Solana.RawMessage.Instruction? = null
809
+ for (instr in decoded.transaction.legacy.instructionsList) {
810
+ val prog = accounts.getOrNull(instr.programId)
811
+ if (systemIx == null && prog == systemProgram) systemIx = instr
812
+ if (splIx == null && prog != null && prog in splPrograms) splIx = instr
813
+ }
814
+
815
+ if (systemIx != null) {
816
+ val ix = systemIx
817
+ val to = if (ix.accountsCount >= 2) accounts.getOrNull(ix.accountsList[1]) else null
818
+ val dataBytes = ix.programData.toByteArray()
819
+ // SystemProgram Transfer discriminator: [2, 0, 0, 0] as u32-LE
820
+ val isTransfer = dataBytes.size >= 12 &&
821
+ dataBytes[0] == 2.toByte() && dataBytes[1] == 0.toByte() &&
822
+ dataBytes[2] == 0.toByte() && dataBytes[3] == 0.toByte()
823
+ val lamports = if (isTransfer) {
824
+ var v = 0UL
825
+ for (i in 0..7) v = v or (dataBytes[4 + i].toUByte().toULong() shl (i * 8))
826
+ v
827
+ } else null
828
+ return SolanaSummary(to, lamports, isTransfer, null, null, false)
829
+ }
830
+
831
+ if (splIx != null) {
832
+ val ix = splIx
833
+ val dataBytes = ix.programData.toByteArray()
834
+ // SPL instruction byte 0: 3 = Transfer, 12 = TransferChecked
835
+ // Transfer: accounts[0]=src, [1]=dest, [2]=owner; data[1..8]=amount LE u64
836
+ // TransferChecked: accounts[0]=src, [1]=mint, [2]=dest, [3]=owner
837
+ return when {
838
+ dataBytes.isNotEmpty() && dataBytes[0] == 3.toByte() && dataBytes.size >= 9 -> {
839
+ val dest = if (ix.accountsCount >= 2) accounts.getOrNull(ix.accountsList[1]) else null
840
+ var amount = 0UL
841
+ for (i in 0..7) amount = amount or (dataBytes[1 + i].toUByte().toULong() shl (i * 8))
842
+ SolanaSummary(null, null, false, dest, amount, true)
843
+ }
844
+ dataBytes.isNotEmpty() && dataBytes[0] == 12.toByte() && dataBytes.size >= 10 -> {
845
+ val dest = if (ix.accountsCount >= 3) accounts.getOrNull(ix.accountsList[2]) else null
846
+ var amount = 0UL
847
+ for (i in 0..7) amount = amount or (dataBytes[1 + i].toUByte().toULong() shl (i * 8))
848
+ SolanaSummary(null, null, false, dest, amount, true)
849
+ }
850
+ else -> SolanaSummary(null, null, false, null, null, false)
851
+ }
852
+ }
853
+
854
+ null
855
+ } catch (_: Exception) { null }
856
+ }
857
+
858
+ private fun txHexToDouble(hex: String): Double = try {
859
+ BigInteger(hex.removePrefix("0x").ifEmpty { "0" }, 16).toDouble()
860
+ } catch (_: NumberFormatException) { 0.0 }
861
+
862
+ private fun fmtAmt(value: Double): String =
863
+ "%.8f".format(value).trimEnd('0').trimEnd('.')
864
+
865
+ private fun fmtAddr(addr: String): String = addr
866
+
867
+ // Helpers
868
+
869
+ private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
870
+
871
+ private fun String.hexToBytes(): ByteArray {
872
+ val s = removePrefix("0x").let { if (it.length % 2 == 0) it else "0$it" }
873
+ return ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
874
+ }
875
+
876
+ // BigInteger -> minimal big-endian ByteString (strips Java's leading sign byte)
877
+ private fun BigInteger.toMinimalByteString(): ByteString {
878
+ val raw = toByteArray()
879
+ return if (raw.size > 1 && raw[0] == 0.toByte()) {
880
+ ByteString.copyFrom(raw, 1, raw.size - 1)
881
+ } else {
882
+ ByteString.copyFrom(raw)
883
+ }
884
+ }