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