@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.
- package/{TrustWalletCoreModule.podspec → ChainberryTrustWalletCoreModule.podspec} +5 -4
- package/README.md +27 -25
- package/android/build.gradle +29 -6
- package/android/libs/README.md +34 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar +0 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar.md5 +1 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.aar.sha1 +1 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom +22 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom.md5 +1 -0
- package/android/libs/com/trustwallet/wallet-core/4.1.19/wallet-core-4.1.19.pom.sha1 +1 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar +0 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar.md5 +1 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.jar.sha1 +1 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom +21 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom.md5 +1 -0
- package/android/libs/com/trustwallet/wallet-core-proto/4.1.19/wallet-core-proto-4.1.19.pom.sha1 +1 -0
- package/android/libs/download.sh +52 -0
- package/android/src/androidTest/java/com/chainberry/trustwalletcore/AddressDerivationConformanceTest.kt +106 -0
- package/android/src/androidTest/java/com/chainberry/trustwalletcore/SigningConformanceTest.kt +186 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/AmountParsing.kt +45 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/Bech32.kt +68 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/ChainSigning.kt +884 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/ChainberryTrustWalletCoreModule.kt +227 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/NativeWalletStore.kt +888 -0
- package/android/src/test/java/com/chainberry/trustwalletcore/AmountParsingConformanceTest.kt +57 -0
- package/android/src/test/java/com/chainberry/trustwalletcore/Bech32Test.kt +35 -0
- package/android/src/test/java/com/chainberry/trustwalletcore/NativeWalletStoreTest.kt +344 -0
- package/expo-module.config.json +3 -2
- package/ios/AmountParsing.swift +62 -0
- package/ios/Bech32.swift +66 -0
- package/ios/ChainSigning.swift +978 -0
- package/ios/ChainberryTrustWalletCoreModule.swift +336 -0
- package/ios/ConformanceTests/AddressDerivationConformanceTests.swift +39 -0
- package/ios/ConformanceTests/SigningConformanceTests.swift +295 -0
- package/ios/NativeWalletStore.swift +288 -0
- package/package.json +4 -3
- package/src/index.ts +42 -13
- package/android/src/main/java/expo/modules/trustwalletcore/ChainSigning.kt +0 -299
- package/android/src/main/java/expo/modules/trustwalletcore/NativeWalletStore.kt +0 -182
- package/android/src/main/java/expo/modules/trustwalletcore/TrustWalletCoreModule.kt +0 -91
- 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
|
+
}
|