@chainberry/trust-wallet-core 2.5.0 → 2.5.2

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.
@@ -3,6 +3,7 @@ import ExpoModulesCore
3
3
  import WalletCore
4
4
 
5
5
  // All chains this module derives addresses for / signs transactions for.
6
+
6
7
  enum ChainKey: String, CaseIterable {
7
8
  case ethereum, bnb, polygon
8
9
  case avax, base, arbitrum, optimism, sonic
@@ -10,6 +11,9 @@ enum ChainKey: String, CaseIterable {
10
11
  case tron, ton
11
12
  case bitcoin, bitcoincash, dogecoin, litecoin
12
13
  case xrp
14
+ case cosmos
15
+ case aptos
16
+ case tezos
13
17
 
14
18
  init(fromJs raw: String) throws {
15
19
  guard let key = ChainKey(rawValue: raw) else {
@@ -36,6 +40,9 @@ enum ChainKey: String, CaseIterable {
36
40
  case .dogecoin: return "DOGE"
37
41
  case .litecoin: return "LTC"
38
42
  case .xrp: return "XRP"
43
+ case .cosmos: return "ATOM"
44
+ case .aptos: return "APT"
45
+ case .tezos: return "XTZ"
39
46
  }
40
47
  }
41
48
 
@@ -52,6 +59,9 @@ enum ChainKey: String, CaseIterable {
52
59
  case .dogecoin: return .dogecoin
53
60
  case .litecoin: return .litecoin
54
61
  case .xrp: return .xrp
62
+ case .cosmos: return .cosmos
63
+ case .aptos: return .aptos
64
+ case .tezos: return .tezos
55
65
  }
56
66
  }
57
67
  }
@@ -147,6 +157,12 @@ enum ChainSigner {
147
157
  return try signTon(wallet: wallet, txParams: unsignedTx)
148
158
  case .bitcoincash:
149
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)
150
166
  }
151
167
  }
152
168
 
@@ -239,7 +255,7 @@ enum ChainSigner {
239
255
  // TW's sanctioned path. We pass the same blockhash back (no-op refresh) so the
240
256
  // tx content is unchanged — only the signature is added.
241
257
  let decodedData = TransactionDecoder.decode(coinType: .solana, encodedTx: txData)
242
- let decoded = try SolanaDecodingTransactionOutput(serializedData: decodedData)
258
+ let decoded = try SolanaDecodingTransactionOutput(serializedBytes: decodedData)
243
259
  guard decoded.error == .ok else {
244
260
  throw Exception(name: "DecodingFailed", description: "Failed to decode SOL tx: \(decoded.errorMessage)")
245
261
  }
@@ -247,12 +263,10 @@ enum ChainSigner {
247
263
 
248
264
  let privateKeys = DataVector()
249
265
  privateKeys.add(data: privateKey.data)
250
- guard let outputData = SolanaTransaction.updateBlockhashAndSign(
266
+ let outputData = SolanaTransaction.updateBlockhashAndSign(
251
267
  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)
268
+ )
269
+ let output = try SolanaSigningOutput(serializedBytes: outputData)
256
270
  guard output.error == .ok else {
257
271
  throw Exception(name: "SigningFailed", description: output.errorMessage)
258
272
  }
@@ -389,26 +403,29 @@ enum ChainSigner {
389
403
  private static func signTron(wallet: HDWallet, txParams: [String: Any]) throws -> String {
390
404
  let privateKey = wallet.getKeyForCoin(coin: .tron)
391
405
 
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.
406
+ // Sign via txID wallet-core reads the txID digest and signs it directly.
394
407
  // 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")
408
+ guard let txID = txParams["txID"] as? String else {
409
+ throw Exception(name: "InvalidParams", description: "Missing txID in TRX tx params")
401
410
  }
402
411
 
403
412
  var input = TronSigningInput()
404
413
  input.privateKey = privateKey.data
405
- input.rawJson = jsonStr
414
+ input.txID = txID
406
415
 
407
416
  let output: TronSigningOutput = AnySigner.sign(input: input, coin: .tron)
408
417
  guard output.error == .ok else {
409
418
  throw Exception(name: "SigningFailed", description: output.errorMessage)
410
419
  }
411
- return output.json
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
412
429
  }
413
430
 
414
431
  // MARK: - XRP
@@ -433,15 +450,15 @@ enum ChainSigner {
433
450
  payment.amount = try parseXrpAmountDrops(amountDrops)
434
451
  payment.destination = destination
435
452
  if let resolvedTag = try parseXrpDestinationTag(destinationTag) {
436
- payment.destinationTag = resolvedTag
453
+ payment.destinationTag = Int64(resolvedTag)
437
454
  }
438
455
 
439
456
  var input = RippleSigningInput()
440
457
  input.privateKey = privateKey.data
441
458
  input.account = account
442
459
  input.fee = try parseXrpFeeDrops(feeDrops)
443
- input.sequence = UInt32(sequence)
444
- if let lls = lastLedgerSequence { input.lastLedgerSequence = UInt32(lls) }
460
+ input.sequence = Int32(sequence)
461
+ if let lls = lastLedgerSequence { input.lastLedgerSequence = Int32(lls) }
445
462
  input.opPayment = payment
446
463
 
447
464
  let output: RippleSigningOutput = AnySigner.sign(input: input, coin: .xrp)
@@ -469,14 +486,11 @@ enum ChainSigner {
469
486
 
470
487
  let privateKey = wallet.getKeyForCoin(coin: .ton)
471
488
 
472
- // amount is Data (uint128 big-endian); encode the nanoton UInt64 as 8 big-endian bytes.
473
489
  let nanotons = try parseTonNanotons(amountStr)
474
- var bigEndianNano = nanotons.bigEndian
475
- let amountData = withUnsafeBytes(of: &bigEndianNano) { Data($0) }
476
490
 
477
491
  var transfer = TheOpenNetworkTransfer()
478
492
  transfer.dest = toAddress
479
- transfer.amount = amountData
493
+ transfer.amount = nanotons
480
494
  transfer.mode = UInt32(TheOpenNetworkSendMode.payFeesSeparately.rawValue | TheOpenNetworkSendMode.ignoreActionPhaseErrors.rawValue)
481
495
  transfer.bounceable = true
482
496
  if let memoId { transfer.comment = memoId }
@@ -495,12 +509,179 @@ enum ChainSigner {
495
509
  return ChainSigner.Result(signedTx: output.encoded, meta: ["txHash": output.hash.hexString])
496
510
  }
497
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
+
498
679
  // MARK: - Transaction summary for native confirmation UI
499
680
 
500
- static func buildSummary(chain: ChainKey, unsignedTx: [String: Any]) -> String {
681
+ static func buildSummary(chain: ChainKey, unsignedTx: [String: Any]) throws -> String {
501
682
  var lines = ["Network: \(chain.rawValue.uppercased())"]
502
683
  switch chain {
503
- case .ethereum, .bnb, .polygon:
684
+ case .ethereum, .bnb, .polygon, .avax, .base, .arbitrum, .optimism, .sonic:
504
685
  if let to = unsignedTx["to"] as? String { lines.append("To: \(fmtAddr(to))") }
505
686
  let val_ = txHexToDouble((unsignedTx["valueHex"] as? String) ?? "0")
506
687
  lines.append("Amount: \(fmtAmt(val_ / 1e18)) \(chain.symbol)")
@@ -510,12 +691,41 @@ enum ChainSigner {
510
691
  )
511
692
  let fee = gasLimit * gasPrice
512
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
+ }
513
716
 
514
717
  case .bitcoin, .dogecoin, .litecoin:
515
718
  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
- }
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)") }
519
729
 
520
730
  case .xrp:
521
731
  if let dest = unsignedTx["Destination"] as? String { lines.append("To: \(fmtAddr(dest))") }
@@ -532,32 +742,208 @@ enum ChainSigner {
532
742
  if let nano = (unsignedTx["amount"] as? String).flatMap(UInt64.init) {
533
743
  lines.append("Amount: \(fmtAmt(Double(nano) / 1e9)) TON")
534
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)")
535
749
 
536
750
  case .tron:
537
751
  if let rawData = unsignedTx["raw_data"] as? [String: Any],
538
752
  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") }
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
+ // Verify txID == SHA256(raw_data_hex). raw_data_hex must be present — fail closed if absent
783
+ // so a JS caller cannot suppress the integrity check by omitting the field.
784
+ guard let txID = unsignedTx["txID"] as? String else {
785
+ throw Exception(name: "TxIntegrityFailed", description: "TRX txID missing — signing refused")
543
786
  }
787
+ guard let rawHex = unsignedTx["raw_data_hex"] as? String,
788
+ let rawBytes = hexData(rawHex) else {
789
+ throw Exception(name: "TxIntegrityFailed",
790
+ description: "TRX raw_data_hex missing — cannot verify txID, signing refused")
791
+ }
792
+ let computed = Hash.sha256(data: rawBytes)
793
+ let computedHex = computed.map { String(format: "%02x", $0) }.joined()
794
+ guard computedHex.lowercased() == txID.lowercased() else {
795
+ throw Exception(name: "TxIntegrityFailed",
796
+ description: "TRX txID does not match SHA256(raw_data_hex) — signing refused")
797
+ }
798
+ lines.append("TxID verified ✓")
544
799
 
545
800
  case .solana:
546
- lines.append("(Solana details verified by the network)")
801
+ // Decode the pre-built tx to extract recipient and lamports (SOL) or destination and
802
+ // amount (SPL token). Falls closed — throws if the tx cannot be decoded at all.
803
+ guard let info = decodeSolanaForSummary(unsignedTx) else {
804
+ throw Exception(name: "UndecodableTx",
805
+ description: "Cannot decode Solana transaction — signing refused to prevent blind signing")
806
+ }
807
+ if info.isSplTransfer {
808
+ if let dest = info.splDest { lines.append("SPL Token to: \(fmtAddr(dest))") }
809
+ if let amt = info.splAmount { lines.append("SPL Token amount (raw): \(amt)") }
810
+ } else {
811
+ if let to = info.to { lines.append("To: \(fmtAddr(to))") }
812
+ if let lamports = info.lamports { lines.append("Amount: \(fmtAmt(Double(lamports) / 1e9)) SOL") }
813
+ if !info.isTransfer { lines.append("Non-transfer instruction — review carefully") }
814
+ }
547
815
 
548
816
  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
- }
817
+ // Fail closed — if descriptor is absent or unparseable we cannot show what will be signed.
818
+ guard let descriptorJson = unsignedTx["unsignedDescriptorJson"] as? String,
819
+ let data = descriptorJson.data(using: .utf8),
820
+ let descriptor = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
821
+ throw Exception(name: "UndecodableTx",
822
+ description: "Cannot decode BCH descriptor — signing refused to prevent blind signing")
823
+ }
824
+ if let to = descriptor["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
825
+ if let sats = (descriptor["sendAmountSats"] as? NSNumber)?.int64Value {
826
+ lines.append("Amount: \(fmtAmt(Double(sats) / 1e8)) BCH")
827
+ }
828
+ if let change = descriptor["changeAddress"] as? String { lines.append("Change to: \(fmtAddr(change))") }
829
+ if let spb = (descriptor["satsPerByte"] as? NSNumber)?.intValue { lines.append("Fee rate: \(spb) sat/vB") }
830
+ let bchInputs = (descriptor["inputs"] as? [[String: Any]])?
831
+ .compactMap { ($0["amountSats"] as? NSNumber)?.int64Value }
832
+ .reduce(Int64(0), +) ?? 0
833
+ let bchSend = (descriptor["sendAmountSats"] as? NSNumber)?.int64Value ?? 0
834
+ let bchChange = (descriptor["changeAmountSats"] as? NSNumber)?.int64Value ?? 0
835
+ let bchFee = bchInputs - bchSend - bchChange
836
+ if bchFee > 0 { lines.append("Total fee: \(fmtAmt(Double(bchFee) / 1e8)) BCH") }
837
+
838
+ case .cosmos:
839
+ if let to = unsignedTx["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
840
+ if let uatom = (unsignedTx["amount"] as? String).flatMap(Int64.init) {
841
+ lines.append("Amount: \(fmtAmt(Double(uatom) / 1_000_000)) ATOM")
842
+ }
843
+ if let feeUatom = (unsignedTx["feeAmount"] as? String).flatMap(Int64.init) {
844
+ lines.append("Fee: \(fmtAmt(Double(feeUatom) / 1_000_000)) ATOM")
845
+ }
846
+
847
+ case .aptos:
848
+ if let to = unsignedTx["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
849
+ if let octas = (unsignedTx["amount"] as? String).flatMap(UInt64.init) {
850
+ lines.append("Amount: \(fmtAmt(Double(octas) / 1e8)) APT")
851
+ }
852
+ if let maxGas = (unsignedTx["maxGasAmount"] as? NSNumber)?.uint64Value,
853
+ let gasPrice = (unsignedTx["gasUnitPrice"] as? NSNumber)?.uint64Value {
854
+ let feeOctas = maxGas * gasPrice
855
+ lines.append("Max fee: \(fmtAmt(Double(feeOctas) / 1e8)) APT")
856
+ }
857
+
858
+ case .tezos:
859
+ if let to = unsignedTx["toAddress"] as? String { lines.append("To: \(fmtAddr(to))") }
860
+ if let mutez = (unsignedTx["amount"] as? NSNumber)?.int64Value {
861
+ lines.append("Amount: \(fmtAmt(Double(mutez) / 1_000_000)) XTZ")
862
+ }
863
+ if let feeMutez = (unsignedTx["fee"] as? NSNumber)?.int64Value {
864
+ lines.append("Fee: \(fmtAmt(Double(feeMutez) / 1_000_000)) XTZ")
865
+ }
866
+ if let reveal = unsignedTx["needsReveal"] as? Bool, reveal {
867
+ lines.append("(includes reveal operation)")
556
868
  }
557
869
  }
558
870
  return lines.joined(separator: "\n")
559
871
  }
560
872
 
873
+ private struct SolanaSummaryInfo {
874
+ let to: String?
875
+ let lamports: UInt64?
876
+ let isTransfer: Bool
877
+ let splDest: String?
878
+ let splAmount: UInt64?
879
+ let isSplTransfer: Bool
880
+ }
881
+
882
+ private static func decodeSolanaForSummary(_ txParams: [String: Any]) -> SolanaSummaryInfo? {
883
+ guard let b64 = txParams["unsignedTxBase64"] as? String,
884
+ let txData = Data(base64Encoded: b64) else { return nil }
885
+ let rawBytes = TransactionDecoder.decode(coinType: .solana, encodedTx: txData)
886
+ guard let decoded = try? SolanaDecodingTransactionOutput(serializedBytes: rawBytes),
887
+ decoded.error == .ok else { return nil }
888
+ let accounts = decoded.transaction.legacy.accountKeys
889
+ let instrs = decoded.transaction.legacy.instructions
890
+
891
+ let systemProgram = "11111111111111111111111111111111"
892
+ // SPL Token Program and Token-2022
893
+ let splPrograms: Set<String> = [
894
+ "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
895
+ "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
896
+ ]
897
+
898
+ // Find first SystemProgram instruction (skip ComputeBudget etc.)
899
+ // and first SPL Token instruction in a single pass.
900
+ var systemIx: TW_Solana_Proto_RawMessage.Instruction? = nil
901
+ var splIx: TW_Solana_Proto_RawMessage.Instruction? = nil
902
+ for instr in instrs {
903
+ let prog = safeGet(accounts, Int(instr.programID)) ?? ""
904
+ if systemIx == nil && prog == systemProgram { systemIx = instr }
905
+ if splIx == nil && splPrograms.contains(prog) { splIx = instr }
906
+ }
907
+
908
+ if let ix = systemIx {
909
+ let to: String? = ix.accounts.count >= 2 ? safeGet(accounts, Int(ix.accounts[1])) : nil
910
+ // SystemProgram Transfer discriminator: [2, 0, 0, 0] as u32-LE
911
+ let isTransfer = ix.programData.count >= 12 && ix.programData.prefix(4) == Data([2, 0, 0, 0])
912
+ var lamports: UInt64?
913
+ if isTransfer {
914
+ var v: UInt64 = 0
915
+ for (i, b) in ix.programData.dropFirst(4).prefix(8).enumerated() { v |= UInt64(b) << (i * 8) }
916
+ lamports = v
917
+ }
918
+ return SolanaSummaryInfo(to: to, lamports: lamports, isTransfer: isTransfer,
919
+ splDest: nil, splAmount: nil, isSplTransfer: false)
920
+ }
921
+
922
+ if let ix = splIx {
923
+ let data = ix.programData
924
+ // SPL instruction byte 0: 3 = Transfer, 12 = TransferChecked
925
+ // Transfer: accounts[0]=src, accounts[1]=dest, accounts[2]=owner; data[1..8]=amount LE u64
926
+ // TransferChecked: accounts[0]=src, accounts[1]=mint, accounts[2]=dest, accounts[3]=owner
927
+ if !data.isEmpty && data[0] == 3 && data.count >= 9 {
928
+ let dest = ix.accounts.count >= 2 ? safeGet(accounts, Int(ix.accounts[1])) : nil
929
+ var amount: UInt64 = 0
930
+ for (i, b) in data.dropFirst(1).prefix(8).enumerated() { amount |= UInt64(b) << (i * 8) }
931
+ return SolanaSummaryInfo(to: nil, lamports: nil, isTransfer: false,
932
+ splDest: dest, splAmount: amount, isSplTransfer: true)
933
+ } else if !data.isEmpty && data[0] == 12 && data.count >= 10 {
934
+ let dest = ix.accounts.count >= 3 ? safeGet(accounts, Int(ix.accounts[2])) : nil
935
+ var amount: UInt64 = 0
936
+ for (i, b) in data.dropFirst(1).prefix(8).enumerated() { amount |= UInt64(b) << (i * 8) }
937
+ return SolanaSummaryInfo(to: nil, lamports: nil, isTransfer: false,
938
+ splDest: dest, splAmount: amount, isSplTransfer: true)
939
+ }
940
+ return SolanaSummaryInfo(to: nil, lamports: nil, isTransfer: false,
941
+ splDest: nil, splAmount: nil, isSplTransfer: false)
942
+ }
943
+
944
+ return nil
945
+ }
946
+
561
947
  private static func txHexToDouble(_ hex: String) -> Double {
562
948
  let s = hex.hasPrefix("0x") ? String(hex.dropFirst(2)) : hex
563
949
  if let v = UInt64(s, radix: 16) { return Double(v) }
@@ -575,6 +961,10 @@ enum ChainSigner {
575
961
 
576
962
  // MARK: - Helpers
577
963
 
964
+ private static func safeGet<T>(_ array: [T], _ index: Int) -> T? {
965
+ array.indices.contains(index) ? array[index] : nil
966
+ }
967
+
578
968
  // Parses a hex string (with or without 0x, odd or even length) into Data. An empty string
579
969
  // deliberately maps to a single zero byte (fields like valueHex already default to "0"
580
970
  // when absent — that's a legitimate zero-value transfer, not malformed input). Any