@chainberry/trust-wallet-core 2.0.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/{TrustWalletCoreModule.podspec → ChainberryTrustWalletCoreModule.podspec} +2 -2
  2. package/README.md +15 -22
  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 +526 -0
  23. package/android/src/main/java/com/chainberry/trustwalletcore/ChainberryTrustWalletCoreModule.kt +147 -0
  24. package/android/src/main/java/com/chainberry/trustwalletcore/NativeWalletStore.kt +796 -0
  25. package/android/src/test/java/com/chainberry/trustwalletcore/AmountParsingConformanceTest.kt +57 -0
  26. package/android/src/test/java/com/chainberry/trustwalletcore/Bech32Test.kt +35 -0
  27. package/android/src/test/java/com/chainberry/trustwalletcore/NativeWalletStoreTest.kt +274 -0
  28. package/expo-module.config.json +3 -2
  29. package/ios/AmountParsing.swift +62 -0
  30. package/ios/Bech32.swift +66 -0
  31. package/ios/ChainSigning.swift +602 -0
  32. package/ios/ChainberryTrustWalletCoreModule.swift +231 -0
  33. package/ios/NativeWalletStore.swift +232 -0
  34. package/package.json +4 -3
  35. package/src/index.ts +27 -12
  36. package/android/src/main/java/expo/modules/trustwalletcore/ChainSigning.kt +0 -299
  37. package/android/src/main/java/expo/modules/trustwalletcore/NativeWalletStore.kt +0 -182
  38. package/android/src/main/java/expo/modules/trustwalletcore/TrustWalletCoreModule.kt +0 -91
  39. package/ios/TrustWalletCoreModule.swift +0 -107
@@ -0,0 +1,602 @@
1
+ import Foundation
2
+ import ExpoModulesCore
3
+ import WalletCore
4
+
5
+ // All chains this module derives addresses for / signs transactions for.
6
+ enum ChainKey: String, CaseIterable {
7
+ case ethereum, bnb, polygon
8
+ case avax, base, arbitrum, optimism, sonic
9
+ case solana
10
+ case tron, ton
11
+ case bitcoin, bitcoincash, dogecoin, litecoin
12
+ case xrp
13
+
14
+ init(fromJs raw: String) throws {
15
+ guard let key = ChainKey(rawValue: raw) else {
16
+ throw Exception(name: "UnsupportedChain", description: "Unsupported chain: \(raw)")
17
+ }
18
+ self = key
19
+ }
20
+
21
+ var symbol: String {
22
+ switch self {
23
+ case .ethereum: return "ETH"
24
+ case .bnb: return "BNB"
25
+ case .polygon: return "POL"
26
+ case .avax: return "AVAX"
27
+ case .base: return "ETH"
28
+ case .arbitrum: return "ETH"
29
+ case .optimism: return "ETH"
30
+ case .sonic: return "S"
31
+ case .solana: return "SOL"
32
+ case .tron: return "TRX"
33
+ case .ton: return "TON"
34
+ case .bitcoin: return "BTC"
35
+ case .bitcoincash: return "BCH"
36
+ case .dogecoin: return "DOGE"
37
+ case .litecoin: return "LTC"
38
+ case .xrp: return "XRP"
39
+ }
40
+ }
41
+
42
+ // All EVM chains share Ethereum's secp256k1 key/address (BIP44 slip44 = 60) — no distinct CoinType.
43
+ var coinType: CoinType {
44
+ switch self {
45
+ case .ethereum, .polygon, .avax, .base, .arbitrum, .optimism, .sonic: return .ethereum
46
+ case .bnb: return .smartChain
47
+ case .solana: return .solana
48
+ case .tron: return .tron
49
+ case .ton: return .ton
50
+ case .bitcoin: return .bitcoin
51
+ case .bitcoincash: return .bitcoinCash
52
+ case .dogecoin: return .dogecoin
53
+ case .litecoin: return .litecoin
54
+ case .xrp: return .xrp
55
+ }
56
+ }
57
+ }
58
+
59
+ enum ChainSigner {
60
+ struct Result {
61
+ let signedTx: String
62
+ let meta: [String: Any]?
63
+ }
64
+
65
+ // SLIP-44 dedicates coin_type 1' to "testnet" for every coin — so a shared literal path
66
+ // would make Litecoin and Bitcoin Cash derive the *same* key (BIP32 derivation only depends
67
+ // on (seed, path, curve), and CoinType alone doesn't perturb it when the path and curve —
68
+ // secp256k1 for both — are identical). Disambiguate by using each coin's own SLIP-44 index
69
+ // as the account (3rd) path component. `purpose` follows the usual BIP44/49/84 convention
70
+ // (44' legacy, 84' native segwit) matching the address style each coin actually gets below.
71
+ private static func utxoTestnetPath(_ chain: ChainKey, purpose: Int) -> String {
72
+ // .rawValue is this coin's SLIP-44 id (same value `signUtxo` already sends as
73
+ // `input.coinType = coin.rawValue` below) — reusing it here instead of an unverified
74
+ // `.slip44Id` accessor keeps this to APIs already proven to exist in this binding.
75
+ "m/\(purpose)'/1'/\(chain.coinType.rawValue)'/0/0"
76
+ }
77
+
78
+ private static let litecoinTestnetHRP = "tltc"
79
+
80
+ // Bitcoin Cash testnet legacy P2PKH version byte (0x6F) — same value Bitcoin/Litecoin
81
+ // testnets use for their base58 legacy prefix. BCH has no bech32/cashaddr testnet support in
82
+ // wallet-core (cashaddr is a different, more involved encoding than bech32 — unlike
83
+ // Litecoin below, not reimplemented here), so it stays on this legacy fallback.
84
+ private static let bchTestnetP2PKHPrefix: UInt8 = 0x6F
85
+
86
+ /// Address for `chain`, honoring `isTestnet`.
87
+ ///
88
+ /// wallet-core's coin registry only carries a real testnet derivation for Bitcoin
89
+ /// (`.bitcoinTestnet`, native segwit — same "bc1"→"tb1" style shift as mainnet). Litecoin and
90
+ /// Bitcoin Cash have no testnet entry at all (no CoinType, no Derivation):
91
+ /// - Litecoin gets a hand-rolled native-segwit bech32 address (see Bech32.swift) — the same
92
+ /// style as its own mainnet "ltc1..." address, just hrp "tltc" instead of "ltc". wallet-
93
+ /// core's `SegwitAddress` can't do this itself (its HRP is a closed native enum with no
94
+ /// "tltc" entry), so this reimplements the encode half of BIP-173 by hand.
95
+ /// - Bitcoin Cash gets a legacy P2PKH address instead — a different *style* from its own
96
+ /// mainnet cashaddr address (cashaddr testnet isn't implemented), but still a real,
97
+ /// correctly-testnet-flagged one.
98
+ static func address(for chain: ChainKey, wallet: HDWallet, isTestnet: Bool) -> String {
99
+ guard isTestnet else { return wallet.getAddressForCoin(coin: chain.coinType) }
100
+ switch chain {
101
+ case .bitcoin:
102
+ return wallet.getAddressDerivation(coin: .bitcoin, derivation: .bitcoinTestnet)
103
+ case .litecoin:
104
+ let pubKey = key(for: chain, wallet: wallet, isTestnet: true).getPublicKeySecp256k1(compressed: true)
105
+ return Bech32.encodeSegwitV0(hrp: litecoinTestnetHRP, program: pubKey.bitcoinKeyHash)
106
+ case .bitcoincash:
107
+ let pubKey = key(for: chain, wallet: wallet, isTestnet: true).getPublicKeySecp256k1(compressed: true)
108
+ return BitcoinAddress(publicKey: pubKey, prefix: bchTestnetP2PKHPrefix)!.description
109
+ // Everything else (EVM/Solana/Tron/Ton/Xrp) shares one address format across
110
+ // mainnet/testnet — only the RPC endpoint differs, which lives entirely in JS.
111
+ default:
112
+ return wallet.getAddressForCoin(coin: chain.coinType)
113
+ }
114
+ }
115
+
116
+ /// The signing key for `chain`, honoring `isTestnet` — must always derive the same key
117
+ /// `address(for:)` used, or a UTXO signer builds a transaction that can't spend the
118
+ /// wallet's own funds (wrong key ⇒ different scriptPubKey than what's actually sitting at
119
+ /// the receive address it was given).
120
+ private static func key(for chain: ChainKey, wallet: HDWallet, isTestnet: Bool) -> PrivateKey {
121
+ guard isTestnet else { return wallet.getKeyForCoin(coin: chain.coinType) }
122
+ switch chain {
123
+ case .bitcoin:
124
+ return wallet.getKeyDerivation(coin: .bitcoin, derivation: .bitcoinTestnet)
125
+ case .litecoin:
126
+ return wallet.getKey(coin: chain.coinType, derivationPath: utxoTestnetPath(chain, purpose: 84))
127
+ case .bitcoincash:
128
+ return wallet.getKey(coin: chain.coinType, derivationPath: utxoTestnetPath(chain, purpose: 44))
129
+ default:
130
+ return wallet.getKeyForCoin(coin: chain.coinType)
131
+ }
132
+ }
133
+
134
+ static func sign(chain: ChainKey, wallet: HDWallet, unsignedTx: [String: Any], isTestnet: Bool) throws -> Result {
135
+ switch chain {
136
+ case .ethereum, .bnb, .polygon, .avax, .base, .arbitrum, .optimism, .sonic:
137
+ return Result(signedTx: try signEvm(wallet: wallet, coin: chain.coinType, txParams: unsignedTx), meta: nil)
138
+ case .solana:
139
+ return Result(signedTx: try signSolana(wallet: wallet, txParams: unsignedTx), meta: nil)
140
+ case .bitcoin, .dogecoin, .litecoin:
141
+ return Result(signedTx: try signUtxo(wallet: wallet, chain: chain, txParams: unsignedTx, isTestnet: isTestnet), meta: nil)
142
+ case .tron:
143
+ return Result(signedTx: try signTron(wallet: wallet, txParams: unsignedTx), meta: nil)
144
+ case .xrp:
145
+ return Result(signedTx: try signXrp(wallet: wallet, txParams: unsignedTx), meta: nil)
146
+ case .ton:
147
+ return try signTon(wallet: wallet, txParams: unsignedTx)
148
+ case .bitcoincash:
149
+ return Result(signedTx: try signBch(wallet: wallet, txParams: unsignedTx), meta: nil)
150
+ }
151
+ }
152
+
153
+ // MARK: - EVM (ethereum / bnb / polygon)
154
+ // txParams: { to, chainId, nonce, gasLimitHex, valueHex?, dataHex?,
155
+ // gasPriceHex? (legacy) | maxFeePerGasHex + maxPriorityFeePerGasHex (EIP-1559) }
156
+ // Hex fields are bare hex (no 0x prefix required) — mirrors what wallet.ts's buildTxParams sends.
157
+
158
+ private static func signEvm(wallet: HDWallet, coin: CoinType, txParams: [String: Any]) throws -> String {
159
+ guard let to = txParams["to"] as? String,
160
+ let nonceNum = txParams["nonce"] as? NSNumber,
161
+ let gasLimHex = txParams["gasLimitHex"] as? String,
162
+ let chainIdNum = txParams["chainId"] as? NSNumber else {
163
+ throw Exception(name: "InvalidParams", description: "Missing required EVM tx params")
164
+ }
165
+ let nonce = nonceNum.intValue
166
+ let chainId = chainIdNum.intValue
167
+ let valueHex = (txParams["valueHex"] as? String) ?? "0"
168
+ let dataHex = (txParams["dataHex"] as? String) ?? ""
169
+
170
+ let privateKey = wallet.getKeyForCoin(coin: coin)
171
+
172
+ guard let gasLimitData = hexData(gasLimHex) else {
173
+ throw Exception(name: "InvalidParams", description: "Invalid gasLimitHex: \(gasLimHex)")
174
+ }
175
+ guard let valueData = hexData(valueHex) else {
176
+ throw Exception(name: "InvalidParams", description: "Invalid valueHex: \(valueHex)")
177
+ }
178
+
179
+ var input = EthereumSigningInput()
180
+ input.chainID = intToData(chainId)
181
+ input.nonce = intToData(nonce)
182
+ input.gasLimit = gasLimitData
183
+ input.toAddress = to
184
+ input.privateKey = privateKey.data
185
+ var transfer = EthereumTransaction.Transfer()
186
+ transfer.amount = valueData
187
+ if !dataHex.isEmpty {
188
+ guard let dataBytes = hexData(dataHex) else {
189
+ throw Exception(name: "InvalidParams", description: "Invalid dataHex: \(dataHex)")
190
+ }
191
+ transfer.data = dataBytes
192
+ }
193
+ var tx = EthereumTransaction()
194
+ tx.transfer = transfer
195
+ input.transaction = tx
196
+
197
+ if let gasPriceHex = txParams["gasPriceHex"] as? String {
198
+ guard let gasPriceData = hexData(gasPriceHex) else {
199
+ throw Exception(name: "InvalidParams", description: "Invalid gasPriceHex: \(gasPriceHex)")
200
+ }
201
+ input.gasPrice = gasPriceData
202
+ } else if let mfHex = txParams["maxFeePerGasHex"] as? String,
203
+ let pfHex = txParams["maxPriorityFeePerGasHex"] as? String {
204
+ guard let maxFeeData = hexData(mfHex) else {
205
+ throw Exception(name: "InvalidParams", description: "Invalid maxFeePerGasHex: \(mfHex)")
206
+ }
207
+ guard let maxPriorityData = hexData(pfHex) else {
208
+ throw Exception(name: "InvalidParams", description: "Invalid maxPriorityFeePerGasHex: \(pfHex)")
209
+ }
210
+ input.txMode = .enveloped
211
+ input.maxFeePerGas = maxFeeData
212
+ input.maxInclusionFeePerGas = maxPriorityData
213
+ }
214
+
215
+ let output: EthereumSigningOutput = AnySigner.sign(input: input, coin: coin)
216
+ guard output.error == .ok else {
217
+ throw Exception(name: "SigningFailed", description: output.errorMessage)
218
+ }
219
+ return "0x" + output.encoded.hexString
220
+ }
221
+
222
+ // MARK: - Solana
223
+ // NOTE(verify-on-device): prepareSelfCustodyUnsignedTx's SOL branch (chainberry-wallet)
224
+ // returns a fully-built, base64-serialized unsigned @solana/web3.js Transaction (supports
225
+ // both native transfer and SPL-token transfer instructions) — not a simple {to,lamports,
226
+ // recentBlockhash} triple. wallet-core's SolanaSigningInput has a raw-message signing mode
227
+ // (`rawMessage` / legacy transaction bytes) intended for exactly this "sign an externally
228
+ // built transaction" case — confirm the exact field name against the installed wallet-core
229
+ // version and adjust the input construction below accordingly before relying on this path.
230
+ // txParams: { unsignedTxBase64: string }
231
+ private static func signSolana(wallet: HDWallet, txParams: [String: Any]) throws -> String {
232
+ guard let unsignedTxBase64 = txParams["unsignedTxBase64"] as? String,
233
+ let txData = Data(base64Encoded: unsignedTxBase64) else {
234
+ throw Exception(name: "InvalidParams", description: "Missing/invalid unsignedTxBase64")
235
+ }
236
+ let privateKey = wallet.getKeyForCoin(coin: .solana)
237
+
238
+ // Decode the unsigned tx to extract the embedded recentBlockhash, then re-sign via
239
+ // TW's sanctioned path. We pass the same blockhash back (no-op refresh) so the
240
+ // tx content is unchanged — only the signature is added.
241
+ let decodedData = TransactionDecoder.decode(coinType: .solana, encodedTx: txData)
242
+ let decoded = try SolanaDecodingTransactionOutput(serializedData: decodedData)
243
+ guard decoded.error == .ok else {
244
+ throw Exception(name: "DecodingFailed", description: "Failed to decode SOL tx: \(decoded.errorMessage)")
245
+ }
246
+ let recentBlockhash = decoded.transaction.legacy.recentBlockhash
247
+
248
+ let privateKeys = DataVector()
249
+ privateKeys.add(data: privateKey.data)
250
+ guard let outputData = SolanaTransaction.updateBlockhashAndSign(
251
+ encodedTx: unsignedTxBase64, recentBlockhash: recentBlockhash, privateKeys: privateKeys
252
+ ) else {
253
+ throw Exception(name: "SigningFailed", description: "SolanaTransaction.updateBlockhashAndSign returned nil")
254
+ }
255
+ let output = try SolanaSigningOutput(serializedData: outputData)
256
+ guard output.error == .ok else {
257
+ throw Exception(name: "SigningFailed", description: output.errorMessage)
258
+ }
259
+ return output.encoded
260
+ }
261
+
262
+ // MARK: - BCH (UTXO-based, replay-protected)
263
+ // txParams: { unsignedDescriptorJson: string }
264
+ // descriptor (from wallet-broadcast's prepareBchTransaction):
265
+ // { inputs: [{ txid, vout, satoshis, scriptPubKeyHex }], toAddress, sendAmountSats,
266
+ // changeAddress?, changeSats? }
267
+ // BCH uses SIGHASH_ALL | SIGHASH_FORK_ID (0x41) for replay protection — distinct from
268
+ // BTC/LTC's plain SIGHASH_ALL (0x01).
269
+ private static func signBch(wallet: HDWallet, txParams: [String: Any]) throws -> String {
270
+ guard let descriptorJson = txParams["unsignedDescriptorJson"] as? String,
271
+ let descriptorData = descriptorJson.data(using: .utf8),
272
+ let descriptor = try? JSONSerialization.jsonObject(with: descriptorData) as? [String: Any],
273
+ let toAddress = descriptor["toAddress"] as? String,
274
+ let sendAmountSats = (descriptor["sendAmountSats"] as? NSNumber)?.int64Value,
275
+ let inputs = descriptor["inputs"] as? [[String: Any]] else {
276
+ throw Exception(name: "InvalidParams", description: "Invalid BCH descriptor JSON")
277
+ }
278
+
279
+ let privateKey = wallet.getKeyForCoin(coin: .bitcoinCash)
280
+
281
+ var input = BitcoinSigningInput()
282
+ input.hashType = 0x41 // SIGHASH_ALL | SIGHASH_FORK_ID (BCH replay protection)
283
+ input.amount = sendAmountSats
284
+ input.byteFee = 1 // BCH fees are minimal; 1 sat/byte is the ecosystem standard
285
+ input.toAddress = toAddress
286
+ if let changeAddress = descriptor["changeAddress"] as? String {
287
+ input.changeAddress = changeAddress
288
+ }
289
+ input.useMaxAmount = false
290
+ input.coinType = CoinType.bitcoinCash.rawValue
291
+ input.privateKey = [privateKey.data]
292
+
293
+ input.utxo = try inputs.map { entry in
294
+ guard let txid = entry["txid"] as? String,
295
+ let voutNum = entry["vout"] as? NSNumber,
296
+ let satoshisNum = entry["satoshis"] as? NSNumber,
297
+ let scriptHex = entry["scriptPubKeyHex"] as? String,
298
+ let scriptData = hexData(scriptHex),
299
+ var txIdData = hexData(txid) else {
300
+ throw Exception(name: "InvalidParams", description: "Invalid BCH UTXO entry")
301
+ }
302
+ txIdData.reverse()
303
+
304
+ var outPoint = BitcoinOutPoint()
305
+ outPoint.hash = txIdData
306
+ outPoint.index = UInt32(voutNum.intValue)
307
+
308
+ var utxo = BitcoinUnspentTransaction()
309
+ utxo.outPoint = outPoint
310
+ utxo.amount = satoshisNum.int64Value
311
+ utxo.script = scriptData
312
+ return utxo
313
+ }
314
+
315
+ let output: BitcoinSigningOutput = AnySigner.sign(input: input, coin: .bitcoinCash)
316
+ guard output.error == .ok else {
317
+ throw Exception(name: "SigningFailed", description: output.errorMessage)
318
+ }
319
+ return output.encoded.hexString
320
+ }
321
+
322
+ // MARK: - BTC / LTC (UTXO-based)
323
+ // txParams (from wallet-broadcast's additive prepareBtcUtxoSet/prepareLtcUtxoSet):
324
+ // { toAddress, changeAddress, sendAmountSats, changeAmountSats, satsPerByte,
325
+ // inputs: [{ txIdHex, vout, amountSats, scriptPubKeyHex }] }
326
+ // NOTE(verify-on-device): field names (hashType/toAddress/changeAddress/byteFee/utxo/
327
+ // outPoint/privateKey) match wallet-core's long-documented Bitcoin signing example, but
328
+ // confirm against the installed 4.1.19 generated Swift types before trusting with real funds.
329
+ private static func signUtxo(wallet: HDWallet, chain: ChainKey, txParams: [String: Any], isTestnet: Bool) throws -> String {
330
+ guard let toAddress = txParams["toAddress"] as? String,
331
+ let changeAddress = txParams["changeAddress"] as? String,
332
+ let sendAmountSats = (txParams["sendAmountSats"] as? String).flatMap(Int64.init),
333
+ let satsPerByteNum = txParams["satsPerByte"] as? NSNumber,
334
+ let inputs = txParams["inputs"] as? [[String: Any]] else {
335
+ throw Exception(name: "InvalidParams", description: "Missing required UTXO tx params")
336
+ }
337
+
338
+ let coin = chain.coinType
339
+ let privateKey = key(for: chain, wallet: wallet, isTestnet: isTestnet)
340
+
341
+ var input = BitcoinSigningInput()
342
+ input.hashType = 1 // SIGHASH_ALL — stable Bitcoin protocol constant, not a wallet-core-specific value
343
+ input.amount = sendAmountSats
344
+ input.byteFee = Int64(satsPerByteNum.doubleValue.rounded(.up))
345
+ input.toAddress = toAddress
346
+ input.changeAddress = changeAddress
347
+ input.useMaxAmount = false
348
+ input.coinType = coin.rawValue
349
+ input.privateKey = [privateKey.data]
350
+
351
+ input.utxo = try inputs.map { entry in
352
+ guard let txIdHex = entry["txIdHex"] as? String,
353
+ let voutNum = entry["vout"] as? NSNumber,
354
+ let amount = (entry["amountSats"] as? String).flatMap(Int64.init),
355
+ let scriptHex = entry["scriptPubKeyHex"] as? String,
356
+ let scriptData = hexData(scriptHex),
357
+ var txIdData = hexData(txIdHex) else {
358
+ throw Exception(name: "InvalidParams", description: "Invalid UTXO input entry")
359
+ }
360
+ // On-chain/explorer txid hex is displayed big-endian; wallet-core's OutPoint.hash wants
361
+ // the reversed (little-endian, internal wire-format) byte order.
362
+ txIdData.reverse()
363
+
364
+ var outPoint = BitcoinOutPoint()
365
+ outPoint.hash = txIdData
366
+ outPoint.index = UInt32(voutNum.intValue)
367
+
368
+ var utxo = BitcoinUnspentTransaction()
369
+ utxo.outPoint = outPoint
370
+ utxo.amount = amount
371
+ utxo.script = scriptData
372
+ return utxo
373
+ }
374
+
375
+ let output: BitcoinSigningOutput = AnySigner.sign(input: input, coin: coin)
376
+ guard output.error == .ok else {
377
+ throw Exception(name: "SigningFailed", description: output.errorMessage)
378
+ }
379
+ return output.encoded.hexString
380
+ }
381
+
382
+ // MARK: - TRX
383
+ // txParams: the raw TronGrid-shaped unsigned tx object from prepareSelfCustodyUnsignedTx's
384
+ // TRX branch (raw_data.contract[], ref_block_bytes/hash, expiration, timestamp).
385
+ // NOTE(verify-on-device): field mapping into TronSigningInput/TronTransaction must be checked
386
+ // against the installed wallet-core version's Tron.proto — this covers the plain TRX-transfer
387
+ // contract type only (matches prepareTrxTransaction's non-token branch); the TRC20
388
+ // (triggerSmartContract) branch is not mapped here and needs its own contract-call SigningInput.
389
+ private static func signTron(wallet: HDWallet, txParams: [String: Any]) throws -> String {
390
+ let privateKey = wallet.getKeyForCoin(coin: .tron)
391
+
392
+ // Pass the full TronGrid unsigned tx JSON via rawJson — wallet-core's direct-sign path
393
+ // reads txID from the JSON and signs that digest, returning the complete signed tx in output.json.
394
+ // This covers both plain TRX transfers and TRC20 triggerSmartContract payloads.
395
+ guard JSONSerialization.isValidJSONObject(txParams) else {
396
+ throw Exception(name: "InvalidParams", description: "TRX tx is not JSON-serializable")
397
+ }
398
+ let jsonData = try JSONSerialization.data(withJSONObject: txParams)
399
+ guard let jsonStr = String(data: jsonData, encoding: .utf8) else {
400
+ throw Exception(name: "InvalidParams", description: "TRX tx JSON encoding failed")
401
+ }
402
+
403
+ var input = TronSigningInput()
404
+ input.privateKey = privateKey.data
405
+ input.rawJson = jsonStr
406
+
407
+ let output: TronSigningOutput = AnySigner.sign(input: input, coin: .tron)
408
+ guard output.error == .ok else {
409
+ throw Exception(name: "SigningFailed", description: output.errorMessage)
410
+ }
411
+ return output.json
412
+ }
413
+
414
+ // MARK: - XRP
415
+ // txParams: xrpl.js `Payment` object from prepareSelfCustodyUnsignedTx's XRP branch
416
+ // (Account, Destination, Amount (drops, string), Fee (drops, string), Sequence,
417
+ // LastLedgerSequence, DestinationTag?).
418
+ private static func signXrp(wallet: HDWallet, txParams: [String: Any]) throws -> String {
419
+ guard let account = txParams["Account"] as? String,
420
+ let destination = txParams["Destination"] as? String,
421
+ let amountDrops = txParams["Amount"] as? String,
422
+ let feeDrops = txParams["Fee"] as? String,
423
+ let sequenceNum = txParams["Sequence"] as? NSNumber else {
424
+ throw Exception(name: "InvalidParams", description: "Missing required XRP Payment fields")
425
+ }
426
+ let sequence = sequenceNum.intValue
427
+ let lastLedgerSequence = (txParams["LastLedgerSequence"] as? NSNumber)?.intValue
428
+ let destinationTag = (txParams["DestinationTag"] as? NSNumber)?.intValue
429
+
430
+ let privateKey = wallet.getKeyForCoin(coin: .xrp)
431
+
432
+ var payment = RippleOperationPayment()
433
+ payment.amount = try parseXrpAmountDrops(amountDrops)
434
+ payment.destination = destination
435
+ if let resolvedTag = try parseXrpDestinationTag(destinationTag) {
436
+ payment.destinationTag = resolvedTag
437
+ }
438
+
439
+ var input = RippleSigningInput()
440
+ input.privateKey = privateKey.data
441
+ input.account = account
442
+ input.fee = try parseXrpFeeDrops(feeDrops)
443
+ input.sequence = UInt32(sequence)
444
+ if let lls = lastLedgerSequence { input.lastLedgerSequence = UInt32(lls) }
445
+ input.opPayment = payment
446
+
447
+ let output: RippleSigningOutput = AnySigner.sign(input: input, coin: .xrp)
448
+ guard output.error == .ok else {
449
+ throw Exception(name: "SigningFailed", description: output.errorMessage)
450
+ }
451
+ return output.encoded.hexString
452
+ }
453
+
454
+ // MARK: - TON
455
+ // txParams: { toAddress, amount, seqno, memoId? } from prepareSelfCustodyUnsignedTx's TON
456
+ // branch. `amount` there is a human-readable TON string (see chainberry-wallet's TON case,
457
+ // which passes the raw `amount` param through unconverted) — NOTE(verify-on-device): confirm
458
+ // whether that's already nanoton or needs *1e9 conversion, and confirm wallet_version V4R2
459
+ // matches the address format the current @ton/* implementation derives (address-format parity
460
+ // is the main risk for this chain per the migration plan).
461
+ private static func signTon(wallet: HDWallet, txParams: [String: Any]) throws -> ChainSigner.Result {
462
+ guard let toAddress = txParams["toAddress"] as? String,
463
+ let amountStr = txParams["amount"] as? String,
464
+ let seqnoNum = txParams["seqno"] as? NSNumber else {
465
+ throw Exception(name: "InvalidParams", description: "Missing required TON tx params")
466
+ }
467
+ let seqno = seqnoNum.intValue
468
+ let memoId = txParams["memoId"] as? String
469
+
470
+ let privateKey = wallet.getKeyForCoin(coin: .ton)
471
+
472
+ // amount is Data (uint128 big-endian); encode the nanoton UInt64 as 8 big-endian bytes.
473
+ let nanotons = try parseTonNanotons(amountStr)
474
+ var bigEndianNano = nanotons.bigEndian
475
+ let amountData = withUnsafeBytes(of: &bigEndianNano) { Data($0) }
476
+
477
+ var transfer = TheOpenNetworkTransfer()
478
+ transfer.dest = toAddress
479
+ transfer.amount = amountData
480
+ transfer.mode = UInt32(TheOpenNetworkSendMode.payFeesSeparately.rawValue | TheOpenNetworkSendMode.ignoreActionPhaseErrors.rawValue)
481
+ transfer.bounceable = true
482
+ if let memoId { transfer.comment = memoId }
483
+
484
+ var input = TheOpenNetworkSigningInput()
485
+ input.privateKey = privateKey.data
486
+ input.walletVersion = .walletV4R2
487
+ input.sequenceNumber = UInt32(seqno)
488
+ input.expireAt = UInt32(Date().timeIntervalSince1970) + 600
489
+ input.messages = [transfer]
490
+
491
+ let output: TheOpenNetworkSigningOutput = AnySigner.sign(input: input, coin: .ton)
492
+ guard output.error == .ok else {
493
+ throw Exception(name: "SigningFailed", description: output.errorMessage)
494
+ }
495
+ return ChainSigner.Result(signedTx: output.encoded, meta: ["txHash": output.hash.hexString])
496
+ }
497
+
498
+ // MARK: - Transaction summary for native confirmation UI
499
+
500
+ static func buildSummary(chain: ChainKey, unsignedTx: [String: Any]) -> String {
501
+ var lines = ["Network: \(chain.rawValue.uppercased())"]
502
+ switch chain {
503
+ case .ethereum, .bnb, .polygon:
504
+ if let to = unsignedTx["to"] as? String { lines.append("To: \(fmtAddr(to))") }
505
+ let val_ = txHexToDouble((unsignedTx["valueHex"] as? String) ?? "0")
506
+ lines.append("Amount: \(fmtAmt(val_ / 1e18)) \(chain.symbol)")
507
+ let gasLimit = txHexToDouble((unsignedTx["gasLimitHex"] as? String) ?? "0")
508
+ let gasPrice = txHexToDouble(
509
+ (unsignedTx["gasPriceHex"] as? String) ?? (unsignedTx["maxFeePerGasHex"] as? String) ?? "0"
510
+ )
511
+ let fee = gasLimit * gasPrice
512
+ if fee > 0 { lines.append("Max fee: \(fmtAmt(fee / 1e18)) \(chain.symbol)") }
513
+
514
+ case .bitcoin, .dogecoin, .litecoin:
515
+ if let to = unsignedTx["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
516
+ if let sats = (unsignedTx["sendAmountSats"] as? String).flatMap(Int64.init) {
517
+ lines.append("Amount: \(fmtAmt(Double(sats) / 1e8)) \(chain.symbol)")
518
+ }
519
+
520
+ case .xrp:
521
+ if let dest = unsignedTx["Destination"] as? String { lines.append("To: \(fmtAddr(dest))") }
522
+ if let drops = (unsignedTx["Amount"] as? String).flatMap(Int64.init) {
523
+ lines.append("Amount: \(fmtAmt(Double(drops) / 1_000_000)) XRP")
524
+ }
525
+ if let feeDrops = (unsignedTx["Fee"] as? String).flatMap(Int64.init) {
526
+ lines.append("Fee: \(fmtAmt(Double(feeDrops) / 1_000_000)) XRP")
527
+ }
528
+ if let tag = unsignedTx["DestinationTag"] { lines.append("Tag: \(tag)") }
529
+
530
+ case .ton:
531
+ if let to = unsignedTx["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
532
+ if let nano = (unsignedTx["amount"] as? String).flatMap(UInt64.init) {
533
+ lines.append("Amount: \(fmtAmt(Double(nano) / 1e9)) TON")
534
+ }
535
+
536
+ case .tron:
537
+ if let rawData = unsignedTx["raw_data"] as? [String: Any],
538
+ let contracts = rawData["contract"] as? [[String: Any]],
539
+ let param = contracts.first?["parameter"] as? [String: Any],
540
+ let value = param["value"] as? [String: Any] {
541
+ if let to = value["to_address"] as? String { lines.append("To: \(fmtAddr(to))") }
542
+ if let amount = value["amount"] as? Int { lines.append("Amount: \(fmtAmt(Double(amount) / 1e6)) TRX") }
543
+ }
544
+
545
+ case .solana:
546
+ lines.append("(Solana — details verified by the network)")
547
+
548
+ case .bitcoincash:
549
+ if let descriptorJson = unsignedTx["unsignedDescriptorJson"] as? String,
550
+ let data = descriptorJson.data(using: .utf8),
551
+ let descriptor = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
552
+ if let to = descriptor["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
553
+ if let sats = (descriptor["sendAmountSats"] as? NSNumber)?.int64Value {
554
+ lines.append("Amount: \(fmtAmt(Double(sats) / 1e8)) BCH")
555
+ }
556
+ }
557
+ }
558
+ return lines.joined(separator: "\n")
559
+ }
560
+
561
+ private static func txHexToDouble(_ hex: String) -> Double {
562
+ let s = hex.hasPrefix("0x") ? String(hex.dropFirst(2)) : hex
563
+ if let v = UInt64(s, radix: 16) { return Double(v) }
564
+ return hexData(s)?.reduce(0.0) { $0 * 256 + Double($1) } ?? 0
565
+ }
566
+
567
+ private static func fmtAmt(_ value: Double) -> String {
568
+ var s = String(format: "%.8f", value)
569
+ while s.hasSuffix("0") { s.removeLast() }
570
+ if s.hasSuffix(".") { s.removeLast() }
571
+ return s
572
+ }
573
+
574
+ private static func fmtAddr(_ addr: String) -> String { addr }
575
+
576
+ // MARK: - Helpers
577
+
578
+ // Parses a hex string (with or without 0x, odd or even length) into Data. An empty string
579
+ // deliberately maps to a single zero byte (fields like valueHex already default to "0"
580
+ // when absent — that's a legitimate zero-value transfer, not malformed input). Any
581
+ // non-empty string containing a non-hex character returns nil so callers can fail closed
582
+ // instead of silently coercing garbage into a zero/empty value.
583
+ static func hexData(_ hex: String) -> Data? {
584
+ let s = hex.hasPrefix("0x") ? String(hex.dropFirst(2)) : hex
585
+ let padded = s.count % 2 == 0 ? s : "0" + s
586
+ guard !padded.isEmpty else { return Data([0]) }
587
+ guard padded.allSatisfy({ $0.isHexDigit }) else { return nil }
588
+ return Data(hexString: padded)
589
+ }
590
+
591
+ // Encodes a non-negative integer as minimal big-endian Data.
592
+ static func intToData(_ value: Int) -> Data {
593
+ guard value > 0 else { return Data([0]) }
594
+ var v = value
595
+ var bytes: [UInt8] = []
596
+ while v > 0 {
597
+ bytes.insert(UInt8(v & 0xFF), at: 0)
598
+ v >>= 8
599
+ }
600
+ return Data(bytes)
601
+ }
602
+ }