@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,978 @@
1
+ import Foundation
2
+ import ExpoModulesCore
3
+ import WalletCore
4
+
5
+ // All chains this module derives addresses for / signs transactions for.
6
+
7
+ enum ChainKey: String, CaseIterable {
8
+ case ethereum, bnb, polygon
9
+ case avax, base, arbitrum, optimism, sonic
10
+ case solana
11
+ case tron, ton
12
+ case bitcoin, bitcoincash, dogecoin, litecoin
13
+ case xrp
14
+ case cosmos
15
+ case aptos
16
+ case tezos
17
+
18
+ init(fromJs raw: String) throws {
19
+ guard let key = ChainKey(rawValue: raw) else {
20
+ throw Exception(name: "UnsupportedChain", description: "Unsupported chain: \(raw)")
21
+ }
22
+ self = key
23
+ }
24
+
25
+ var symbol: String {
26
+ switch self {
27
+ case .ethereum: return "ETH"
28
+ case .bnb: return "BNB"
29
+ case .polygon: return "POL"
30
+ case .avax: return "AVAX"
31
+ case .base: return "ETH"
32
+ case .arbitrum: return "ETH"
33
+ case .optimism: return "ETH"
34
+ case .sonic: return "S"
35
+ case .solana: return "SOL"
36
+ case .tron: return "TRX"
37
+ case .ton: return "TON"
38
+ case .bitcoin: return "BTC"
39
+ case .bitcoincash: return "BCH"
40
+ case .dogecoin: return "DOGE"
41
+ case .litecoin: return "LTC"
42
+ case .xrp: return "XRP"
43
+ case .cosmos: return "ATOM"
44
+ case .aptos: return "APT"
45
+ case .tezos: return "XTZ"
46
+ }
47
+ }
48
+
49
+ // All EVM chains share Ethereum's secp256k1 key/address (BIP44 slip44 = 60) — no distinct CoinType.
50
+ var coinType: CoinType {
51
+ switch self {
52
+ case .ethereum, .polygon, .avax, .base, .arbitrum, .optimism, .sonic: return .ethereum
53
+ case .bnb: return .smartChain
54
+ case .solana: return .solana
55
+ case .tron: return .tron
56
+ case .ton: return .ton
57
+ case .bitcoin: return .bitcoin
58
+ case .bitcoincash: return .bitcoinCash
59
+ case .dogecoin: return .dogecoin
60
+ case .litecoin: return .litecoin
61
+ case .xrp: return .xrp
62
+ case .cosmos: return .cosmos
63
+ case .aptos: return .aptos
64
+ case .tezos: return .tezos
65
+ }
66
+ }
67
+ }
68
+
69
+ enum ChainSigner {
70
+ struct Result {
71
+ let signedTx: String
72
+ let meta: [String: Any]?
73
+ }
74
+
75
+ // SLIP-44 dedicates coin_type 1' to "testnet" for every coin — so a shared literal path
76
+ // would make Litecoin and Bitcoin Cash derive the *same* key (BIP32 derivation only depends
77
+ // on (seed, path, curve), and CoinType alone doesn't perturb it when the path and curve —
78
+ // secp256k1 for both — are identical). Disambiguate by using each coin's own SLIP-44 index
79
+ // as the account (3rd) path component. `purpose` follows the usual BIP44/49/84 convention
80
+ // (44' legacy, 84' native segwit) matching the address style each coin actually gets below.
81
+ private static func utxoTestnetPath(_ chain: ChainKey, purpose: Int) -> String {
82
+ // .rawValue is this coin's SLIP-44 id (same value `signUtxo` already sends as
83
+ // `input.coinType = coin.rawValue` below) — reusing it here instead of an unverified
84
+ // `.slip44Id` accessor keeps this to APIs already proven to exist in this binding.
85
+ "m/\(purpose)'/1'/\(chain.coinType.rawValue)'/0/0"
86
+ }
87
+
88
+ private static let litecoinTestnetHRP = "tltc"
89
+
90
+ // Bitcoin Cash testnet legacy P2PKH version byte (0x6F) — same value Bitcoin/Litecoin
91
+ // testnets use for their base58 legacy prefix. BCH has no bech32/cashaddr testnet support in
92
+ // wallet-core (cashaddr is a different, more involved encoding than bech32 — unlike
93
+ // Litecoin below, not reimplemented here), so it stays on this legacy fallback.
94
+ private static let bchTestnetP2PKHPrefix: UInt8 = 0x6F
95
+
96
+ /// Address for `chain`, honoring `isTestnet`.
97
+ ///
98
+ /// wallet-core's coin registry only carries a real testnet derivation for Bitcoin
99
+ /// (`.bitcoinTestnet`, native segwit — same "bc1"→"tb1" style shift as mainnet). Litecoin and
100
+ /// Bitcoin Cash have no testnet entry at all (no CoinType, no Derivation):
101
+ /// - Litecoin gets a hand-rolled native-segwit bech32 address (see Bech32.swift) — the same
102
+ /// style as its own mainnet "ltc1..." address, just hrp "tltc" instead of "ltc". wallet-
103
+ /// core's `SegwitAddress` can't do this itself (its HRP is a closed native enum with no
104
+ /// "tltc" entry), so this reimplements the encode half of BIP-173 by hand.
105
+ /// - Bitcoin Cash gets a legacy P2PKH address instead — a different *style* from its own
106
+ /// mainnet cashaddr address (cashaddr testnet isn't implemented), but still a real,
107
+ /// correctly-testnet-flagged one.
108
+ static func address(for chain: ChainKey, wallet: HDWallet, isTestnet: Bool) -> String {
109
+ guard isTestnet else { return wallet.getAddressForCoin(coin: chain.coinType) }
110
+ switch chain {
111
+ case .bitcoin:
112
+ return wallet.getAddressDerivation(coin: .bitcoin, derivation: .bitcoinTestnet)
113
+ case .litecoin:
114
+ let pubKey = key(for: chain, wallet: wallet, isTestnet: true).getPublicKeySecp256k1(compressed: true)
115
+ return Bech32.encodeSegwitV0(hrp: litecoinTestnetHRP, program: pubKey.bitcoinKeyHash)
116
+ case .bitcoincash:
117
+ let pubKey = key(for: chain, wallet: wallet, isTestnet: true).getPublicKeySecp256k1(compressed: true)
118
+ return BitcoinAddress(publicKey: pubKey, prefix: bchTestnetP2PKHPrefix)!.description
119
+ // Everything else (EVM/Solana/Tron/Ton/Xrp) shares one address format across
120
+ // mainnet/testnet — only the RPC endpoint differs, which lives entirely in JS.
121
+ default:
122
+ return wallet.getAddressForCoin(coin: chain.coinType)
123
+ }
124
+ }
125
+
126
+ /// The signing key for `chain`, honoring `isTestnet` — must always derive the same key
127
+ /// `address(for:)` used, or a UTXO signer builds a transaction that can't spend the
128
+ /// wallet's own funds (wrong key ⇒ different scriptPubKey than what's actually sitting at
129
+ /// the receive address it was given).
130
+ private static func key(for chain: ChainKey, wallet: HDWallet, isTestnet: Bool) -> PrivateKey {
131
+ guard isTestnet else { return wallet.getKeyForCoin(coin: chain.coinType) }
132
+ switch chain {
133
+ case .bitcoin:
134
+ return wallet.getKeyDerivation(coin: .bitcoin, derivation: .bitcoinTestnet)
135
+ case .litecoin:
136
+ return wallet.getKey(coin: chain.coinType, derivationPath: utxoTestnetPath(chain, purpose: 84))
137
+ case .bitcoincash:
138
+ return wallet.getKey(coin: chain.coinType, derivationPath: utxoTestnetPath(chain, purpose: 44))
139
+ default:
140
+ return wallet.getKeyForCoin(coin: chain.coinType)
141
+ }
142
+ }
143
+
144
+ static func sign(chain: ChainKey, wallet: HDWallet, unsignedTx: [String: Any], isTestnet: Bool) throws -> Result {
145
+ switch chain {
146
+ case .ethereum, .bnb, .polygon, .avax, .base, .arbitrum, .optimism, .sonic:
147
+ return Result(signedTx: try signEvm(wallet: wallet, coin: chain.coinType, txParams: unsignedTx), meta: nil)
148
+ case .solana:
149
+ return Result(signedTx: try signSolana(wallet: wallet, txParams: unsignedTx), meta: nil)
150
+ case .bitcoin, .dogecoin, .litecoin:
151
+ return Result(signedTx: try signUtxo(wallet: wallet, chain: chain, txParams: unsignedTx, isTestnet: isTestnet), meta: nil)
152
+ case .tron:
153
+ return Result(signedTx: try signTron(wallet: wallet, txParams: unsignedTx), meta: nil)
154
+ case .xrp:
155
+ return Result(signedTx: try signXrp(wallet: wallet, txParams: unsignedTx), meta: nil)
156
+ case .ton:
157
+ return try signTon(wallet: wallet, txParams: unsignedTx)
158
+ case .bitcoincash:
159
+ return Result(signedTx: try signBch(wallet: wallet, txParams: unsignedTx), meta: nil)
160
+ case .cosmos:
161
+ return Result(signedTx: try signCosmos(wallet: wallet, txParams: unsignedTx), meta: nil)
162
+ case .aptos:
163
+ return Result(signedTx: try signAptos(wallet: wallet, txParams: unsignedTx), meta: nil)
164
+ case .tezos:
165
+ return Result(signedTx: try signTezos(wallet: wallet, txParams: unsignedTx), meta: nil)
166
+ }
167
+ }
168
+
169
+ // MARK: - EVM (ethereum / bnb / polygon)
170
+ // txParams: { to, chainId, nonce, gasLimitHex, valueHex?, dataHex?,
171
+ // gasPriceHex? (legacy) | maxFeePerGasHex + maxPriorityFeePerGasHex (EIP-1559) }
172
+ // Hex fields are bare hex (no 0x prefix required) — mirrors what wallet.ts's buildTxParams sends.
173
+
174
+ private static func signEvm(wallet: HDWallet, coin: CoinType, txParams: [String: Any]) throws -> String {
175
+ guard let to = txParams["to"] as? String,
176
+ let nonceNum = txParams["nonce"] as? NSNumber,
177
+ let gasLimHex = txParams["gasLimitHex"] as? String,
178
+ let chainIdNum = txParams["chainId"] as? NSNumber else {
179
+ throw Exception(name: "InvalidParams", description: "Missing required EVM tx params")
180
+ }
181
+ let nonce = nonceNum.intValue
182
+ let chainId = chainIdNum.intValue
183
+ let valueHex = (txParams["valueHex"] as? String) ?? "0"
184
+ let dataHex = (txParams["dataHex"] as? String) ?? ""
185
+
186
+ let privateKey = wallet.getKeyForCoin(coin: coin)
187
+
188
+ guard let gasLimitData = hexData(gasLimHex) else {
189
+ throw Exception(name: "InvalidParams", description: "Invalid gasLimitHex: \(gasLimHex)")
190
+ }
191
+ guard let valueData = hexData(valueHex) else {
192
+ throw Exception(name: "InvalidParams", description: "Invalid valueHex: \(valueHex)")
193
+ }
194
+
195
+ var input = EthereumSigningInput()
196
+ input.chainID = intToData(chainId)
197
+ input.nonce = intToData(nonce)
198
+ input.gasLimit = gasLimitData
199
+ input.toAddress = to
200
+ input.privateKey = privateKey.data
201
+ var transfer = EthereumTransaction.Transfer()
202
+ transfer.amount = valueData
203
+ if !dataHex.isEmpty {
204
+ guard let dataBytes = hexData(dataHex) else {
205
+ throw Exception(name: "InvalidParams", description: "Invalid dataHex: \(dataHex)")
206
+ }
207
+ transfer.data = dataBytes
208
+ }
209
+ var tx = EthereumTransaction()
210
+ tx.transfer = transfer
211
+ input.transaction = tx
212
+
213
+ if let gasPriceHex = txParams["gasPriceHex"] as? String {
214
+ guard let gasPriceData = hexData(gasPriceHex) else {
215
+ throw Exception(name: "InvalidParams", description: "Invalid gasPriceHex: \(gasPriceHex)")
216
+ }
217
+ input.gasPrice = gasPriceData
218
+ } else if let mfHex = txParams["maxFeePerGasHex"] as? String,
219
+ let pfHex = txParams["maxPriorityFeePerGasHex"] as? String {
220
+ guard let maxFeeData = hexData(mfHex) else {
221
+ throw Exception(name: "InvalidParams", description: "Invalid maxFeePerGasHex: \(mfHex)")
222
+ }
223
+ guard let maxPriorityData = hexData(pfHex) else {
224
+ throw Exception(name: "InvalidParams", description: "Invalid maxPriorityFeePerGasHex: \(pfHex)")
225
+ }
226
+ input.txMode = .enveloped
227
+ input.maxFeePerGas = maxFeeData
228
+ input.maxInclusionFeePerGas = maxPriorityData
229
+ }
230
+
231
+ let output: EthereumSigningOutput = AnySigner.sign(input: input, coin: coin)
232
+ guard output.error == .ok else {
233
+ throw Exception(name: "SigningFailed", description: output.errorMessage)
234
+ }
235
+ return "0x" + output.encoded.hexString
236
+ }
237
+
238
+ // MARK: - Solana
239
+ // NOTE(verify-on-device): prepareSelfCustodyUnsignedTx's SOL branch (chainberry-wallet)
240
+ // returns a fully-built, base64-serialized unsigned @solana/web3.js Transaction (supports
241
+ // both native transfer and SPL-token transfer instructions) — not a simple {to,lamports,
242
+ // recentBlockhash} triple. wallet-core's SolanaSigningInput has a raw-message signing mode
243
+ // (`rawMessage` / legacy transaction bytes) intended for exactly this "sign an externally
244
+ // built transaction" case — confirm the exact field name against the installed wallet-core
245
+ // version and adjust the input construction below accordingly before relying on this path.
246
+ // txParams: { unsignedTxBase64: string }
247
+ private static func signSolana(wallet: HDWallet, txParams: [String: Any]) throws -> String {
248
+ guard let unsignedTxBase64 = txParams["unsignedTxBase64"] as? String,
249
+ let txData = Data(base64Encoded: unsignedTxBase64) else {
250
+ throw Exception(name: "InvalidParams", description: "Missing/invalid unsignedTxBase64")
251
+ }
252
+ let privateKey = wallet.getKeyForCoin(coin: .solana)
253
+
254
+ // Decode the unsigned tx to extract the embedded recentBlockhash, then re-sign via
255
+ // TW's sanctioned path. We pass the same blockhash back (no-op refresh) so the
256
+ // tx content is unchanged — only the signature is added.
257
+ let decodedData = TransactionDecoder.decode(coinType: .solana, encodedTx: txData)
258
+ let decoded = try SolanaDecodingTransactionOutput(serializedBytes: decodedData)
259
+ guard decoded.error == .ok else {
260
+ throw Exception(name: "DecodingFailed", description: "Failed to decode SOL tx: \(decoded.errorMessage)")
261
+ }
262
+ let recentBlockhash = decoded.transaction.legacy.recentBlockhash
263
+
264
+ let privateKeys = DataVector()
265
+ privateKeys.add(data: privateKey.data)
266
+ let outputData = SolanaTransaction.updateBlockhashAndSign(
267
+ encodedTx: unsignedTxBase64, recentBlockhash: recentBlockhash, privateKeys: privateKeys
268
+ )
269
+ let output = try SolanaSigningOutput(serializedBytes: outputData)
270
+ guard output.error == .ok else {
271
+ throw Exception(name: "SigningFailed", description: output.errorMessage)
272
+ }
273
+ return output.encoded
274
+ }
275
+
276
+ // MARK: - BCH (UTXO-based, replay-protected)
277
+ // txParams: { unsignedDescriptorJson: string }
278
+ // descriptor (from wallet-broadcast's prepareBchTransaction):
279
+ // { inputs: [{ txid, vout, satoshis, scriptPubKeyHex }], toAddress, sendAmountSats,
280
+ // changeAddress?, changeSats? }
281
+ // BCH uses SIGHASH_ALL | SIGHASH_FORK_ID (0x41) for replay protection — distinct from
282
+ // BTC/LTC's plain SIGHASH_ALL (0x01).
283
+ private static func signBch(wallet: HDWallet, txParams: [String: Any]) throws -> String {
284
+ guard let descriptorJson = txParams["unsignedDescriptorJson"] as? String,
285
+ let descriptorData = descriptorJson.data(using: .utf8),
286
+ let descriptor = try? JSONSerialization.jsonObject(with: descriptorData) as? [String: Any],
287
+ let toAddress = descriptor["toAddress"] as? String,
288
+ let sendAmountSats = (descriptor["sendAmountSats"] as? NSNumber)?.int64Value,
289
+ let inputs = descriptor["inputs"] as? [[String: Any]] else {
290
+ throw Exception(name: "InvalidParams", description: "Invalid BCH descriptor JSON")
291
+ }
292
+
293
+ let privateKey = wallet.getKeyForCoin(coin: .bitcoinCash)
294
+
295
+ var input = BitcoinSigningInput()
296
+ input.hashType = 0x41 // SIGHASH_ALL | SIGHASH_FORK_ID (BCH replay protection)
297
+ input.amount = sendAmountSats
298
+ input.byteFee = 1 // BCH fees are minimal; 1 sat/byte is the ecosystem standard
299
+ input.toAddress = toAddress
300
+ if let changeAddress = descriptor["changeAddress"] as? String {
301
+ input.changeAddress = changeAddress
302
+ }
303
+ input.useMaxAmount = false
304
+ input.coinType = CoinType.bitcoinCash.rawValue
305
+ input.privateKey = [privateKey.data]
306
+
307
+ input.utxo = try inputs.map { entry in
308
+ guard let txid = entry["txid"] as? String,
309
+ let voutNum = entry["vout"] as? NSNumber,
310
+ let satoshisNum = entry["satoshis"] as? NSNumber,
311
+ let scriptHex = entry["scriptPubKeyHex"] as? String,
312
+ let scriptData = hexData(scriptHex),
313
+ var txIdData = hexData(txid) else {
314
+ throw Exception(name: "InvalidParams", description: "Invalid BCH UTXO entry")
315
+ }
316
+ txIdData.reverse()
317
+
318
+ var outPoint = BitcoinOutPoint()
319
+ outPoint.hash = txIdData
320
+ outPoint.index = UInt32(voutNum.intValue)
321
+
322
+ var utxo = BitcoinUnspentTransaction()
323
+ utxo.outPoint = outPoint
324
+ utxo.amount = satoshisNum.int64Value
325
+ utxo.script = scriptData
326
+ return utxo
327
+ }
328
+
329
+ let output: BitcoinSigningOutput = AnySigner.sign(input: input, coin: .bitcoinCash)
330
+ guard output.error == .ok else {
331
+ throw Exception(name: "SigningFailed", description: output.errorMessage)
332
+ }
333
+ return output.encoded.hexString
334
+ }
335
+
336
+ // MARK: - BTC / LTC (UTXO-based)
337
+ // txParams (from wallet-broadcast's additive prepareBtcUtxoSet/prepareLtcUtxoSet):
338
+ // { toAddress, changeAddress, sendAmountSats, changeAmountSats, satsPerByte,
339
+ // inputs: [{ txIdHex, vout, amountSats, scriptPubKeyHex }] }
340
+ // NOTE(verify-on-device): field names (hashType/toAddress/changeAddress/byteFee/utxo/
341
+ // outPoint/privateKey) match wallet-core's long-documented Bitcoin signing example, but
342
+ // confirm against the installed 4.1.19 generated Swift types before trusting with real funds.
343
+ private static func signUtxo(wallet: HDWallet, chain: ChainKey, txParams: [String: Any], isTestnet: Bool) throws -> String {
344
+ guard let toAddress = txParams["toAddress"] as? String,
345
+ let changeAddress = txParams["changeAddress"] as? String,
346
+ let sendAmountSats = (txParams["sendAmountSats"] as? String).flatMap(Int64.init),
347
+ let satsPerByteNum = txParams["satsPerByte"] as? NSNumber,
348
+ let inputs = txParams["inputs"] as? [[String: Any]] else {
349
+ throw Exception(name: "InvalidParams", description: "Missing required UTXO tx params")
350
+ }
351
+
352
+ let coin = chain.coinType
353
+ let privateKey = key(for: chain, wallet: wallet, isTestnet: isTestnet)
354
+
355
+ var input = BitcoinSigningInput()
356
+ input.hashType = 1 // SIGHASH_ALL — stable Bitcoin protocol constant, not a wallet-core-specific value
357
+ input.amount = sendAmountSats
358
+ input.byteFee = Int64(satsPerByteNum.doubleValue.rounded(.up))
359
+ input.toAddress = toAddress
360
+ input.changeAddress = changeAddress
361
+ input.useMaxAmount = false
362
+ input.coinType = coin.rawValue
363
+ input.privateKey = [privateKey.data]
364
+
365
+ input.utxo = try inputs.map { entry in
366
+ guard let txIdHex = entry["txIdHex"] as? String,
367
+ let voutNum = entry["vout"] as? NSNumber,
368
+ let amount = (entry["amountSats"] as? String).flatMap(Int64.init),
369
+ let scriptHex = entry["scriptPubKeyHex"] as? String,
370
+ let scriptData = hexData(scriptHex),
371
+ var txIdData = hexData(txIdHex) else {
372
+ throw Exception(name: "InvalidParams", description: "Invalid UTXO input entry")
373
+ }
374
+ // On-chain/explorer txid hex is displayed big-endian; wallet-core's OutPoint.hash wants
375
+ // the reversed (little-endian, internal wire-format) byte order.
376
+ txIdData.reverse()
377
+
378
+ var outPoint = BitcoinOutPoint()
379
+ outPoint.hash = txIdData
380
+ outPoint.index = UInt32(voutNum.intValue)
381
+
382
+ var utxo = BitcoinUnspentTransaction()
383
+ utxo.outPoint = outPoint
384
+ utxo.amount = amount
385
+ utxo.script = scriptData
386
+ return utxo
387
+ }
388
+
389
+ let output: BitcoinSigningOutput = AnySigner.sign(input: input, coin: coin)
390
+ guard output.error == .ok else {
391
+ throw Exception(name: "SigningFailed", description: output.errorMessage)
392
+ }
393
+ return output.encoded.hexString
394
+ }
395
+
396
+ // MARK: - TRX
397
+ // txParams: the raw TronGrid-shaped unsigned tx object from prepareSelfCustodyUnsignedTx's
398
+ // TRX branch (raw_data.contract[], ref_block_bytes/hash, expiration, timestamp).
399
+ // NOTE(verify-on-device): field mapping into TronSigningInput/TronTransaction must be checked
400
+ // against the installed wallet-core version's Tron.proto — this covers the plain TRX-transfer
401
+ // contract type only (matches prepareTrxTransaction's non-token branch); the TRC20
402
+ // (triggerSmartContract) branch is not mapped here and needs its own contract-call SigningInput.
403
+ private static func signTron(wallet: HDWallet, txParams: [String: Any]) throws -> String {
404
+ let privateKey = wallet.getKeyForCoin(coin: .tron)
405
+
406
+ // Sign via txID — wallet-core reads the txID digest and signs it directly.
407
+ // This covers both plain TRX transfers and TRC20 triggerSmartContract payloads.
408
+ guard let txID = txParams["txID"] as? String else {
409
+ throw Exception(name: "InvalidParams", description: "Missing txID in TRX tx params")
410
+ }
411
+
412
+ var input = TronSigningInput()
413
+ input.privateKey = privateKey.data
414
+ input.txID = txID
415
+
416
+ let output: TronSigningOutput = AnySigner.sign(input: input, coin: .tron)
417
+ guard output.error == .ok else {
418
+ throw Exception(name: "SigningFailed", description: output.errorMessage)
419
+ }
420
+
421
+ // Reconstruct the full TronGrid broadcast payload with the signature appended.
422
+ var broadcastTx = txParams
423
+ broadcastTx["signature"] = [output.signature.hexString]
424
+ let broadcastData = try JSONSerialization.data(withJSONObject: broadcastTx)
425
+ guard let broadcastJson = String(data: broadcastData, encoding: .utf8) else {
426
+ throw Exception(name: "EncodingFailed", description: "TRX broadcast tx JSON encoding failed")
427
+ }
428
+ return broadcastJson
429
+ }
430
+
431
+ // MARK: - XRP
432
+ // txParams: xrpl.js `Payment` object from prepareSelfCustodyUnsignedTx's XRP branch
433
+ // (Account, Destination, Amount (drops, string), Fee (drops, string), Sequence,
434
+ // LastLedgerSequence, DestinationTag?).
435
+ private static func signXrp(wallet: HDWallet, txParams: [String: Any]) throws -> String {
436
+ guard let account = txParams["Account"] as? String,
437
+ let destination = txParams["Destination"] as? String,
438
+ let amountDrops = txParams["Amount"] as? String,
439
+ let feeDrops = txParams["Fee"] as? String,
440
+ let sequenceNum = txParams["Sequence"] as? NSNumber else {
441
+ throw Exception(name: "InvalidParams", description: "Missing required XRP Payment fields")
442
+ }
443
+ let sequence = sequenceNum.intValue
444
+ let lastLedgerSequence = (txParams["LastLedgerSequence"] as? NSNumber)?.intValue
445
+ let destinationTag = (txParams["DestinationTag"] as? NSNumber)?.intValue
446
+
447
+ let privateKey = wallet.getKeyForCoin(coin: .xrp)
448
+
449
+ var payment = RippleOperationPayment()
450
+ payment.amount = try parseXrpAmountDrops(amountDrops)
451
+ payment.destination = destination
452
+ if let resolvedTag = try parseXrpDestinationTag(destinationTag) {
453
+ payment.destinationTag = Int64(resolvedTag)
454
+ }
455
+
456
+ var input = RippleSigningInput()
457
+ input.privateKey = privateKey.data
458
+ input.account = account
459
+ input.fee = try parseXrpFeeDrops(feeDrops)
460
+ input.sequence = Int32(sequence)
461
+ if let lls = lastLedgerSequence { input.lastLedgerSequence = Int32(lls) }
462
+ input.opPayment = payment
463
+
464
+ let output: RippleSigningOutput = AnySigner.sign(input: input, coin: .xrp)
465
+ guard output.error == .ok else {
466
+ throw Exception(name: "SigningFailed", description: output.errorMessage)
467
+ }
468
+ return output.encoded.hexString
469
+ }
470
+
471
+ // MARK: - TON
472
+ // txParams: { toAddress, amount, seqno, memoId? } from prepareSelfCustodyUnsignedTx's TON
473
+ // branch. `amount` there is a human-readable TON string (see chainberry-wallet's TON case,
474
+ // which passes the raw `amount` param through unconverted) — NOTE(verify-on-device): confirm
475
+ // whether that's already nanoton or needs *1e9 conversion, and confirm wallet_version V4R2
476
+ // matches the address format the current @ton/* implementation derives (address-format parity
477
+ // is the main risk for this chain per the migration plan).
478
+ private static func signTon(wallet: HDWallet, txParams: [String: Any]) throws -> ChainSigner.Result {
479
+ guard let toAddress = txParams["toAddress"] as? String,
480
+ let amountStr = txParams["amount"] as? String,
481
+ let seqnoNum = txParams["seqno"] as? NSNumber else {
482
+ throw Exception(name: "InvalidParams", description: "Missing required TON tx params")
483
+ }
484
+ let seqno = seqnoNum.intValue
485
+ let memoId = txParams["memoId"] as? String
486
+
487
+ let privateKey = wallet.getKeyForCoin(coin: .ton)
488
+
489
+ let nanotons = try parseTonNanotons(amountStr)
490
+
491
+ var transfer = TheOpenNetworkTransfer()
492
+ transfer.dest = toAddress
493
+ transfer.amount = nanotons
494
+ transfer.mode = UInt32(TheOpenNetworkSendMode.payFeesSeparately.rawValue | TheOpenNetworkSendMode.ignoreActionPhaseErrors.rawValue)
495
+ transfer.bounceable = true
496
+ if let memoId { transfer.comment = memoId }
497
+
498
+ var input = TheOpenNetworkSigningInput()
499
+ input.privateKey = privateKey.data
500
+ input.walletVersion = .walletV4R2
501
+ input.sequenceNumber = UInt32(seqno)
502
+ input.expireAt = UInt32(Date().timeIntervalSince1970) + 600
503
+ input.messages = [transfer]
504
+
505
+ let output: TheOpenNetworkSigningOutput = AnySigner.sign(input: input, coin: .ton)
506
+ guard output.error == .ok else {
507
+ throw Exception(name: "SigningFailed", description: output.errorMessage)
508
+ }
509
+ return ChainSigner.Result(signedTx: output.encoded, meta: ["txHash": output.hash.hexString])
510
+ }
511
+
512
+ // MARK: - Cosmos (ATOM)
513
+ // txParams: { accountNumber, sequence, chainId, feeAmount, gas, memo, fromAddress, toAddress,
514
+ // amount (uatom, decimal string), denom }
515
+ // Returns output.serialized — the ready-to-broadcast JSON
516
+ // {"mode":"BROADCAST_MODE_SYNC","tx_bytes":"<base64>"} posted directly to the Cosmos LCD.
517
+ private static func signCosmos(wallet: HDWallet, txParams: [String: Any]) throws -> String {
518
+ guard let fromAddress = txParams["fromAddress"] as? String,
519
+ let toAddress = txParams["toAddress"] as? String,
520
+ let amountStr = txParams["amount"] as? String,
521
+ let feeAmountStr = txParams["feeAmount"] as? String,
522
+ let denom = txParams["denom"] as? String,
523
+ let chainId = txParams["chainId"] as? String,
524
+ let accountNumberNum = txParams["accountNumber"] as? NSNumber,
525
+ let sequenceNum = txParams["sequence"] as? NSNumber,
526
+ let gasNum = txParams["gas"] as? NSNumber else {
527
+ throw Exception(name: "InvalidParams", description: "Missing required Cosmos tx params")
528
+ }
529
+ let memo = (txParams["memo"] as? String) ?? ""
530
+
531
+ let privateKey = wallet.getKeyForCoin(coin: .cosmos)
532
+
533
+ var sendAmount = CosmosAmount()
534
+ sendAmount.denom = denom
535
+ sendAmount.amount = amountStr
536
+
537
+ var send = CosmosMessage.Send()
538
+ send.fromAddress = fromAddress
539
+ send.toAddress = toAddress
540
+ send.amounts = [sendAmount]
541
+
542
+ var message = CosmosMessage()
543
+ message.sendCoinsMessage = send
544
+
545
+ var feeAmt = CosmosAmount()
546
+ feeAmt.denom = denom
547
+ feeAmt.amount = feeAmountStr
548
+
549
+ var fee = CosmosFee()
550
+ fee.amounts = [feeAmt]
551
+ fee.gas = UInt64(gasNum.intValue)
552
+
553
+ var input = CosmosSigningInput()
554
+ input.signingMode = .protobuf
555
+ input.accountNumber = UInt64(accountNumberNum.intValue)
556
+ input.chainID = chainId
557
+ input.sequence = UInt64(sequenceNum.intValue)
558
+ input.memo = memo
559
+ input.fee = fee
560
+ input.messages = [message]
561
+ input.privateKey = privateKey.data
562
+ input.mode = .sync
563
+
564
+ let output: CosmosSigningOutput = AnySigner.sign(input: input, coin: .cosmos)
565
+ guard output.error == .ok else {
566
+ throw Exception(name: "SigningFailed", description: output.errorMessage)
567
+ }
568
+ return output.serialized
569
+ }
570
+
571
+ // MARK: - Aptos (APT)
572
+ // txParams: { sender, sequenceNumber, maxGasAmount, gasUnitPrice, expirationTimestampSecs,
573
+ // chainId, toAddress, amount (octas, decimal string) }
574
+ // Returns output.json — the signed JSON body posted directly to the Aptos REST API.
575
+ private static func signAptos(wallet: HDWallet, txParams: [String: Any]) throws -> String {
576
+ guard let sender = txParams["sender"] as? String,
577
+ let toAddress = txParams["toAddress"] as? String,
578
+ let amountStr = txParams["amount"] as? String,
579
+ let seqNum = txParams["sequenceNumber"] as? NSNumber,
580
+ let maxGas = txParams["maxGasAmount"] as? NSNumber,
581
+ let gasPrice = txParams["gasUnitPrice"] as? NSNumber,
582
+ let expiry = txParams["expirationTimestampSecs"] as? NSNumber,
583
+ let chainId = txParams["chainId"] as? NSNumber else {
584
+ throw Exception(name: "InvalidParams", description: "Missing required Aptos tx params")
585
+ }
586
+ guard let amountOctas = UInt64(amountStr) else {
587
+ throw Exception(name: "InvalidParams", description: "Invalid Aptos amount: \(amountStr)")
588
+ }
589
+
590
+ let privateKey = wallet.getKeyForCoin(coin: .aptos)
591
+
592
+ var transfer = AptosTransferMessage()
593
+ transfer.to = toAddress
594
+ transfer.amount = amountOctas
595
+
596
+ var input = AptosSigningInput()
597
+ input.sender = sender
598
+ input.sequenceNumber = Int64(seqNum.intValue)
599
+ input.maxGasAmount = UInt64(maxGas.intValue)
600
+ input.gasUnitPrice = UInt64(gasPrice.intValue)
601
+ input.expirationTimestampSecs = UInt64(expiry.intValue)
602
+ input.chainID = UInt32(chainId.intValue)
603
+ input.privateKey = privateKey.data
604
+ input.transfer = transfer
605
+
606
+ let output: AptosSigningOutput = AnySigner.sign(input: input, coin: .aptos)
607
+ guard output.error == .ok else {
608
+ throw Exception(name: "SigningFailed", description: output.errorMessage)
609
+ }
610
+ return output.json
611
+ }
612
+
613
+ // MARK: - Tezos (XTZ)
614
+ // txParams: { branch, fromAddress, toAddress, counter, amount (mutez), fee (mutez),
615
+ // gasLimit, storageLimit, needsReveal }
616
+ // Returns output.encoded hex — posted to /injection/operation as a JSON-encoded string.
617
+ private static func signTezos(wallet: HDWallet, txParams: [String: Any]) throws -> String {
618
+ guard let branch = txParams["branch"] as? String,
619
+ let fromAddress = txParams["fromAddress"] as? String,
620
+ let toAddress = txParams["toAddress"] as? String,
621
+ let counterNum = txParams["counter"] as? NSNumber,
622
+ let amountNum = txParams["amount"] as? NSNumber,
623
+ let feeNum = txParams["fee"] as? NSNumber,
624
+ let gasLimitNum = txParams["gasLimit"] as? NSNumber,
625
+ let storageLimitNum = txParams["storageLimit"] as? NSNumber else {
626
+ throw Exception(name: "InvalidParams", description: "Missing required Tezos tx params")
627
+ }
628
+ let needsReveal = (txParams["needsReveal"] as? Bool) ?? false
629
+ let counter = counterNum.int64Value
630
+
631
+ let privateKey = wallet.getKeyForCoin(coin: .tezos)
632
+ var operations: [TezosOperation] = []
633
+
634
+ if needsReveal {
635
+ let pubKey = privateKey.getPublicKeyEd25519()
636
+ var revealData = TezosRevealOperationData()
637
+ revealData.publicKey = pubKey.data
638
+
639
+ var reveal = TezosOperation()
640
+ reveal.source = fromAddress
641
+ reveal.counter = counter - 1
642
+ reveal.fee = 1420
643
+ reveal.gasLimit = 10600
644
+ reveal.storageLimit = 0
645
+ reveal.kind = .reveal
646
+ reveal.revealOperationData = revealData
647
+ operations.append(reveal)
648
+ }
649
+
650
+ var txData = TezosTransactionOperationData()
651
+ txData.destination = toAddress
652
+ txData.amount = amountNum.int64Value
653
+
654
+ var txOp = TezosOperation()
655
+ txOp.source = fromAddress
656
+ txOp.counter = counter
657
+ txOp.fee = feeNum.int64Value
658
+ txOp.gasLimit = gasLimitNum.int64Value
659
+ txOp.storageLimit = storageLimitNum.int64Value
660
+ txOp.kind = .transaction
661
+ txOp.transactionOperationData = txData
662
+ operations.append(txOp)
663
+
664
+ var opList = TezosOperationList()
665
+ opList.branch = branch
666
+ opList.operations = operations
667
+
668
+ var input = TezosSigningInput()
669
+ input.operationList = opList
670
+ input.privateKey = privateKey.data
671
+
672
+ let output: TezosSigningOutput = AnySigner.sign(input: input, coin: .tezos)
673
+ guard output.error == .ok else {
674
+ throw Exception(name: "SigningFailed", description: output.errorMessage)
675
+ }
676
+ return output.encoded.hexString
677
+ }
678
+
679
+ // MARK: - Transaction summary for native confirmation UI
680
+
681
+ static func buildSummary(chain: ChainKey, unsignedTx: [String: Any]) throws -> String {
682
+ var lines = ["Network: \(chain.rawValue.uppercased())"]
683
+ switch chain {
684
+ case .ethereum, .bnb, .polygon, .avax, .base, .arbitrum, .optimism, .sonic:
685
+ if let to = unsignedTx["to"] as? String { lines.append("To: \(fmtAddr(to))") }
686
+ let val_ = txHexToDouble((unsignedTx["valueHex"] as? String) ?? "0")
687
+ lines.append("Amount: \(fmtAmt(val_ / 1e18)) \(chain.symbol)")
688
+ let gasLimit = txHexToDouble((unsignedTx["gasLimitHex"] as? String) ?? "0")
689
+ let gasPrice = txHexToDouble(
690
+ (unsignedTx["gasPriceHex"] as? String) ?? (unsignedTx["maxFeePerGasHex"] as? String) ?? "0"
691
+ )
692
+ let fee = gasLimit * gasPrice
693
+ if fee > 0 { lines.append("Max fee: \(fmtAmt(fee / 1e18)) \(chain.symbol)") }
694
+ if let chainId = unsignedTx["chainId"] as? NSNumber { lines.append("Chain ID: \(chainId.intValue)") }
695
+ if let nonce = unsignedTx["nonce"] as? NSNumber { lines.append("Nonce: \(nonce.intValue)") }
696
+ let dataHex = (unsignedTx["dataHex"] as? String) ?? ""
697
+ let stripped = dataHex.hasPrefix("0x") ? String(dataHex.dropFirst(2)) : dataHex
698
+ if !stripped.isEmpty && stripped != "0" {
699
+ let sel = stripped.prefix(8).lowercased()
700
+ if sel == "a9059cbb", stripped.count >= 136 {
701
+ // transfer(address recipient, uint256 amount)
702
+ let recipient = "0x" + String(stripped.dropFirst(32).prefix(40))
703
+ let amountHex = String(stripped.dropFirst(72).prefix(64)).drop(while: { $0 == "0" })
704
+ lines.append("Token transfer to: \(fmtAddr(recipient))")
705
+ lines.append("Token amount (raw units): 0x\(amountHex.isEmpty ? "0" : String(amountHex))")
706
+ } else if sel == "23b872dd", stripped.count >= 200 {
707
+ // transferFrom(address from, address to, uint256 amount)
708
+ let to = "0x" + String(stripped.dropFirst(96).prefix(40))
709
+ let amountHex = String(stripped.dropFirst(136).prefix(64)).drop(while: { $0 == "0" })
710
+ lines.append("Token transfer to: \(fmtAddr(to))")
711
+ lines.append("Token amount (raw units): 0x\(amountHex.isEmpty ? "0" : String(amountHex))")
712
+ } else {
713
+ lines.append("Contract data: \(stripped.count / 2) bytes — review carefully")
714
+ }
715
+ }
716
+
717
+ case .bitcoin, .dogecoin, .litecoin:
718
+ if let to = unsignedTx["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
719
+ let sendSats = (unsignedTx["sendAmountSats"] as? String).flatMap(Int64.init) ?? 0
720
+ if sendSats > 0 { lines.append("Amount: \(fmtAmt(Double(sendSats) / 1e8)) \(chain.symbol)") }
721
+ if let change = unsignedTx["changeAddress"] as? String { lines.append("Change to: \(fmtAddr(change))") }
722
+ if let spb = unsignedTx["satsPerByte"] as? NSNumber { lines.append("Fee rate: \(spb.intValue) sat/vB") }
723
+ let inputTotal = (unsignedTx["inputs"] as? [[String: Any]])?
724
+ .compactMap { ($0["amountSats"] as? String).flatMap(Int64.init) }
725
+ .reduce(Int64(0), +) ?? 0
726
+ let changeSats = (unsignedTx["changeAmountSats"] as? String).flatMap(Int64.init) ?? 0
727
+ let totalFee = inputTotal - sendSats - changeSats
728
+ if totalFee > 0 { lines.append("Total fee: \(fmtAmt(Double(totalFee) / 1e8)) \(chain.symbol)") }
729
+
730
+ case .xrp:
731
+ if let dest = unsignedTx["Destination"] as? String { lines.append("To: \(fmtAddr(dest))") }
732
+ if let drops = (unsignedTx["Amount"] as? String).flatMap(Int64.init) {
733
+ lines.append("Amount: \(fmtAmt(Double(drops) / 1_000_000)) XRP")
734
+ }
735
+ if let feeDrops = (unsignedTx["Fee"] as? String).flatMap(Int64.init) {
736
+ lines.append("Fee: \(fmtAmt(Double(feeDrops) / 1_000_000)) XRP")
737
+ }
738
+ if let tag = unsignedTx["DestinationTag"] { lines.append("Tag: \(tag)") }
739
+
740
+ case .ton:
741
+ if let to = unsignedTx["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
742
+ if let nano = (unsignedTx["amount"] as? String).flatMap(UInt64.init) {
743
+ lines.append("Amount: \(fmtAmt(Double(nano) / 1e9)) TON")
744
+ }
745
+ let memoTon = unsignedTx["memoId"] as? String
746
+ if let memo = memoTon, !memo.isEmpty { lines.append("Memo: \(memo)") }
747
+ let feeEst = (memoTon?.isEmpty == false) ? "~0.006" : "~0.005"
748
+ lines.append("Fee: \(feeEst) TON (estimate)")
749
+
750
+ case .tron:
751
+ if let rawData = unsignedTx["raw_data"] as? [String: Any],
752
+ let contracts = rawData["contract"] as? [[String: Any]],
753
+ let first = contracts.first {
754
+ let type_ = first["type"] as? String ?? ""
755
+ if let param = first["parameter"] as? [String: Any],
756
+ let value = param["value"] as? [String: Any] {
757
+ switch type_ {
758
+ case "TransferContract":
759
+ if let to = value["to_address"] as? String { lines.append("To: \(fmtAddr(to))") }
760
+ if let amount = value["amount"] as? Int { lines.append("Amount: \(fmtAmt(Double(amount) / 1e6)) TRX") }
761
+ case "TriggerSmartContract":
762
+ if let contractAddr = value["contract_address"] as? String {
763
+ lines.append("Token contract: \(fmtAddr(contractAddr))")
764
+ }
765
+ let dataHex = (value["data"] as? String) ?? ""
766
+ let stripped = dataHex.hasPrefix("0x") ? String(dataHex.dropFirst(2)) : dataHex
767
+ let sel = stripped.prefix(8).lowercased()
768
+ if sel == "a9059cbb" && stripped.count >= 136 {
769
+ // TRC-20 transfer(address, uint256) — ABI encoding identical to EVM
770
+ let recipientHex = "0x" + String(stripped.dropFirst(32).prefix(40))
771
+ let amountHex = String(stripped.dropFirst(72).prefix(64)).drop(while: { $0 == "0" })
772
+ lines.append("TRC-20 to: \(fmtAddr(recipientHex))")
773
+ lines.append("Token amount (raw units): 0x\(amountHex.isEmpty ? "0" : String(amountHex))")
774
+ } else {
775
+ lines.append("Contract call: \(stripped.count / 2) bytes — review carefully")
776
+ }
777
+ default:
778
+ if !type_.isEmpty { lines.append("Contract type: \(type_) — review carefully") }
779
+ }
780
+ }
781
+ }
782
+ if let txID = unsignedTx["txID"] as? String {
783
+ // Verify txID == SHA256(raw_data_hex) to detect a mismatched digest.
784
+ if let rawHex = unsignedTx["raw_data_hex"] as? String,
785
+ let rawBytes = hexData(rawHex) {
786
+ let computed = Hash.sha256(data: rawBytes)
787
+ let computedHex = computed.map { String(format: "%02x", $0) }.joined()
788
+ guard computedHex.lowercased() == txID.lowercased() else {
789
+ throw Exception(name: "TxIntegrityFailed",
790
+ description: "TRX txID does not match SHA256(raw_data_hex) — signing refused")
791
+ }
792
+ lines.append("TxID verified ✓")
793
+ } else {
794
+ lines.append("TxID: \(txID.prefix(16))… (raw_data_hex absent — unverified)")
795
+ }
796
+ }
797
+
798
+ case .solana:
799
+ // Decode the pre-built tx to extract recipient and lamports (SOL) or destination and
800
+ // amount (SPL token). Falls closed — throws if the tx cannot be decoded at all.
801
+ guard let info = decodeSolanaForSummary(unsignedTx) else {
802
+ throw Exception(name: "UndecodableTx",
803
+ description: "Cannot decode Solana transaction — signing refused to prevent blind signing")
804
+ }
805
+ if info.isSplTransfer {
806
+ if let dest = info.splDest { lines.append("SPL Token to: \(fmtAddr(dest))") }
807
+ if let amt = info.splAmount { lines.append("SPL Token amount (raw): \(amt)") }
808
+ } else {
809
+ if let to = info.to { lines.append("To: \(fmtAddr(to))") }
810
+ if let lamports = info.lamports { lines.append("Amount: \(fmtAmt(Double(lamports) / 1e9)) SOL") }
811
+ if !info.isTransfer { lines.append("Non-transfer instruction — review carefully") }
812
+ }
813
+
814
+ case .bitcoincash:
815
+ if let descriptorJson = unsignedTx["unsignedDescriptorJson"] as? String,
816
+ let data = descriptorJson.data(using: .utf8),
817
+ let descriptor = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
818
+ if let to = descriptor["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
819
+ if let sats = (descriptor["sendAmountSats"] as? NSNumber)?.int64Value {
820
+ lines.append("Amount: \(fmtAmt(Double(sats) / 1e8)) BCH")
821
+ }
822
+ }
823
+
824
+ case .cosmos:
825
+ if let to = unsignedTx["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
826
+ if let uatom = (unsignedTx["amount"] as? String).flatMap(Int64.init) {
827
+ lines.append("Amount: \(fmtAmt(Double(uatom) / 1_000_000)) ATOM")
828
+ }
829
+ if let feeUatom = (unsignedTx["feeAmount"] as? String).flatMap(Int64.init) {
830
+ lines.append("Fee: \(fmtAmt(Double(feeUatom) / 1_000_000)) ATOM")
831
+ }
832
+
833
+ case .aptos:
834
+ if let to = unsignedTx["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
835
+ if let octas = (unsignedTx["amount"] as? String).flatMap(UInt64.init) {
836
+ lines.append("Amount: \(fmtAmt(Double(octas) / 1e8)) APT")
837
+ }
838
+ if let maxGas = (unsignedTx["maxGasAmount"] as? NSNumber)?.uint64Value,
839
+ let gasPrice = (unsignedTx["gasUnitPrice"] as? NSNumber)?.uint64Value {
840
+ let feeOctas = maxGas * gasPrice
841
+ lines.append("Max fee: \(fmtAmt(Double(feeOctas) / 1e8)) APT")
842
+ }
843
+
844
+ case .tezos:
845
+ if let to = unsignedTx["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
846
+ if let mutez = (unsignedTx["amount"] as? NSNumber)?.int64Value {
847
+ lines.append("Amount: \(fmtAmt(Double(mutez) / 1_000_000)) XTZ")
848
+ }
849
+ if let feeMutez = (unsignedTx["fee"] as? NSNumber)?.int64Value {
850
+ lines.append("Fee: \(fmtAmt(Double(feeMutez) / 1_000_000)) XTZ")
851
+ }
852
+ if let reveal = unsignedTx["needsReveal"] as? Bool, reveal {
853
+ lines.append("(includes reveal operation)")
854
+ }
855
+ }
856
+ return lines.joined(separator: "\n")
857
+ }
858
+
859
+ private struct SolanaSummaryInfo {
860
+ let to: String?
861
+ let lamports: UInt64?
862
+ let isTransfer: Bool
863
+ let splDest: String?
864
+ let splAmount: UInt64?
865
+ let isSplTransfer: Bool
866
+ }
867
+
868
+ private static func decodeSolanaForSummary(_ txParams: [String: Any]) -> SolanaSummaryInfo? {
869
+ guard let b64 = txParams["unsignedTxBase64"] as? String,
870
+ let txData = Data(base64Encoded: b64) else { return nil }
871
+ let rawBytes = TransactionDecoder.decode(coinType: .solana, encodedTx: txData)
872
+ guard let decoded = try? SolanaDecodingTransactionOutput(serializedBytes: rawBytes),
873
+ decoded.error == .ok else { return nil }
874
+ let accounts = decoded.transaction.legacy.accountKeys
875
+ let instrs = decoded.transaction.legacy.instructions
876
+
877
+ let systemProgram = "11111111111111111111111111111111"
878
+ // SPL Token Program and Token-2022
879
+ let splPrograms: Set<String> = [
880
+ "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
881
+ "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
882
+ ]
883
+
884
+ // Find first SystemProgram instruction (skip ComputeBudget etc.)
885
+ // and first SPL Token instruction in a single pass.
886
+ var systemIx: TW_Solana_Proto_RawMessage.Instruction? = nil
887
+ var splIx: TW_Solana_Proto_RawMessage.Instruction? = nil
888
+ for instr in instrs {
889
+ let prog = safeGet(accounts, Int(instr.programID)) ?? ""
890
+ if systemIx == nil && prog == systemProgram { systemIx = instr }
891
+ if splIx == nil && splPrograms.contains(prog) { splIx = instr }
892
+ }
893
+
894
+ if let ix = systemIx {
895
+ let to: String? = ix.accounts.count >= 2 ? safeGet(accounts, Int(ix.accounts[1])) : nil
896
+ // SystemProgram Transfer discriminator: [2, 0, 0, 0] as u32-LE
897
+ let isTransfer = ix.programData.count >= 12 && ix.programData.prefix(4) == Data([2, 0, 0, 0])
898
+ var lamports: UInt64?
899
+ if isTransfer {
900
+ var v: UInt64 = 0
901
+ for (i, b) in ix.programData.dropFirst(4).prefix(8).enumerated() { v |= UInt64(b) << (i * 8) }
902
+ lamports = v
903
+ }
904
+ return SolanaSummaryInfo(to: to, lamports: lamports, isTransfer: isTransfer,
905
+ splDest: nil, splAmount: nil, isSplTransfer: false)
906
+ }
907
+
908
+ if let ix = splIx {
909
+ let data = ix.programData
910
+ // SPL instruction byte 0: 3 = Transfer, 12 = TransferChecked
911
+ // Transfer: accounts[0]=src, accounts[1]=dest, accounts[2]=owner; data[1..8]=amount LE u64
912
+ // TransferChecked: accounts[0]=src, accounts[1]=mint, accounts[2]=dest, accounts[3]=owner
913
+ if !data.isEmpty && data[0] == 3 && data.count >= 9 {
914
+ let dest = ix.accounts.count >= 2 ? safeGet(accounts, Int(ix.accounts[1])) : nil
915
+ var amount: UInt64 = 0
916
+ for (i, b) in data.dropFirst(1).prefix(8).enumerated() { amount |= UInt64(b) << (i * 8) }
917
+ return SolanaSummaryInfo(to: nil, lamports: nil, isTransfer: false,
918
+ splDest: dest, splAmount: amount, isSplTransfer: true)
919
+ } else if !data.isEmpty && data[0] == 12 && data.count >= 10 {
920
+ let dest = ix.accounts.count >= 3 ? safeGet(accounts, Int(ix.accounts[2])) : nil
921
+ var amount: UInt64 = 0
922
+ for (i, b) in data.dropFirst(1).prefix(8).enumerated() { amount |= UInt64(b) << (i * 8) }
923
+ return SolanaSummaryInfo(to: nil, lamports: nil, isTransfer: false,
924
+ splDest: dest, splAmount: amount, isSplTransfer: true)
925
+ }
926
+ return SolanaSummaryInfo(to: nil, lamports: nil, isTransfer: false,
927
+ splDest: nil, splAmount: nil, isSplTransfer: false)
928
+ }
929
+
930
+ return nil
931
+ }
932
+
933
+ private static func txHexToDouble(_ hex: String) -> Double {
934
+ let s = hex.hasPrefix("0x") ? String(hex.dropFirst(2)) : hex
935
+ if let v = UInt64(s, radix: 16) { return Double(v) }
936
+ return hexData(s)?.reduce(0.0) { $0 * 256 + Double($1) } ?? 0
937
+ }
938
+
939
+ private static func fmtAmt(_ value: Double) -> String {
940
+ var s = String(format: "%.8f", value)
941
+ while s.hasSuffix("0") { s.removeLast() }
942
+ if s.hasSuffix(".") { s.removeLast() }
943
+ return s
944
+ }
945
+
946
+ private static func fmtAddr(_ addr: String) -> String { addr }
947
+
948
+ // MARK: - Helpers
949
+
950
+ private static func safeGet<T>(_ array: [T], _ index: Int) -> T? {
951
+ array.indices.contains(index) ? array[index] : nil
952
+ }
953
+
954
+ // Parses a hex string (with or without 0x, odd or even length) into Data. An empty string
955
+ // deliberately maps to a single zero byte (fields like valueHex already default to "0"
956
+ // when absent — that's a legitimate zero-value transfer, not malformed input). Any
957
+ // non-empty string containing a non-hex character returns nil so callers can fail closed
958
+ // instead of silently coercing garbage into a zero/empty value.
959
+ static func hexData(_ hex: String) -> Data? {
960
+ let s = hex.hasPrefix("0x") ? String(hex.dropFirst(2)) : hex
961
+ let padded = s.count % 2 == 0 ? s : "0" + s
962
+ guard !padded.isEmpty else { return Data([0]) }
963
+ guard padded.allSatisfy({ $0.isHexDigit }) else { return nil }
964
+ return Data(hexString: padded)
965
+ }
966
+
967
+ // Encodes a non-negative integer as minimal big-endian Data.
968
+ static func intToData(_ value: Int) -> Data {
969
+ guard value > 0 else { return Data([0]) }
970
+ var v = value
971
+ var bytes: [UInt8] = []
972
+ while v > 0 {
973
+ bytes.insert(UInt8(v & 0xFF), at: 0)
974
+ v >>= 8
975
+ }
976
+ return Data(bytes)
977
+ }
978
+ }