@chainberry/trust-wallet-core 2.0.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/{TrustWalletCoreModule.podspec → ChainberryTrustWalletCoreModule.podspec} +2 -2
- package/README.md +15 -22
- 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 +526 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/ChainberryTrustWalletCoreModule.kt +147 -0
- package/android/src/main/java/com/chainberry/trustwalletcore/NativeWalletStore.kt +796 -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 +274 -0
- package/expo-module.config.json +3 -2
- package/ios/AmountParsing.swift +62 -0
- package/ios/Bech32.swift +66 -0
- package/ios/ChainSigning.swift +602 -0
- package/ios/ChainberryTrustWalletCoreModule.swift +231 -0
- package/ios/NativeWalletStore.swift +232 -0
- package/package.json +4 -3
- package/src/index.ts +27 -12
- 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,45 @@
|
|
|
1
|
+
package com.chainberry.trustwalletcore
|
|
2
|
+
|
|
3
|
+
// Pure numeric-string parsing for the XRP/TON fields that need to fail closed on malformed
|
|
4
|
+
// input, mirroring ios/AmountParsing.swift 1:1 (same function names/behavior) so both
|
|
5
|
+
// platforms can be checked against the same shared fixture
|
|
6
|
+
// (../../../../../../conformance/amount-parsing-cases.json — see
|
|
7
|
+
// AmountParsingConformanceTest.kt). Android's `String.toLong()`/`toULongOrNull()` already
|
|
8
|
+
// throw/null-out on non-numeric input (unlike iOS's old `Int64(s) ?? 0` pattern), but neither
|
|
9
|
+
// platform previously rejected negative amounts, zero amounts, or an out-of-range
|
|
10
|
+
// DestinationTag — this closes those gaps.
|
|
11
|
+
|
|
12
|
+
private const val XRP_DESTINATION_TAG_MAX = 4_294_967_295L // XRP DestinationTag is a UInt32
|
|
13
|
+
|
|
14
|
+
/** XRP `Amount` (drops) must parse as a strictly positive Long. A non-numeric, decimal,
|
|
15
|
+
* empty, or negative/zero string throws rather than silently signing a zero-value transfer. */
|
|
16
|
+
fun parseXrpAmountDrops(s: String): Long {
|
|
17
|
+
val value = s.toLongOrNull() ?: throw ChainSigningException("Invalid XRP Amount: $s")
|
|
18
|
+
if (value <= 0) throw ChainSigningException("Invalid XRP Amount: $s")
|
|
19
|
+
return value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** XRP `Fee` (drops) must parse as a non-negative Long (0 is a legitimate fee for some
|
|
23
|
+
* transaction types — only genuinely malformed/negative input should throw). */
|
|
24
|
+
fun parseXrpFeeDrops(s: String): Long {
|
|
25
|
+
val value = s.toLongOrNull() ?: throw ChainSigningException("Invalid XRP Fee: $s")
|
|
26
|
+
if (value < 0) throw ChainSigningException("Invalid XRP Fee: $s")
|
|
27
|
+
return value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** XRP DestinationTag is optional; `null` passes through. A present value must be
|
|
31
|
+
* non-negative and fit in 32 bits. */
|
|
32
|
+
fun parseXrpDestinationTag(tag: Long?): Long? {
|
|
33
|
+
if (tag == null) return null
|
|
34
|
+
if (tag < 0 || tag > XRP_DESTINATION_TAG_MAX) {
|
|
35
|
+
throw ChainSigningException("Invalid XRP DestinationTag: must be an integer 0..4294967295")
|
|
36
|
+
}
|
|
37
|
+
return tag
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** TON `amount` (nanotons) must parse as a strictly positive value. */
|
|
41
|
+
fun parseTonNanotons(s: String): Long {
|
|
42
|
+
val value = s.toULongOrNull() ?: throw ChainSigningException("Invalid TON amount: $s")
|
|
43
|
+
if (value == 0uL) throw ChainSigningException("Invalid TON amount: $s")
|
|
44
|
+
return value.toLong()
|
|
45
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
package com.chainberry.trustwalletcore
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal BIP-173 (bech32) segwit-address encoder, witness-version-0 only.
|
|
5
|
+
*
|
|
6
|
+
* wallet-core's own `SegwitAddress` class only accepts an HRP from its closed native `HRP`
|
|
7
|
+
* enum — there's no "tltc" entry (Litecoin testnet isn't in wallet-core's coin registry at
|
|
8
|
+
* all; see ChainSigner.addressForChain). This reimplements just the encode half of BIP-173 by
|
|
9
|
+
* hand so Litecoin testnet can still get a real native-segwit address in the same style as its
|
|
10
|
+
* own mainnet "ltc1..." address, instead of falling back to a legacy P2PKH format.
|
|
11
|
+
*
|
|
12
|
+
* Verified against the official BIP-173 test vectors — see Bech32Test.
|
|
13
|
+
*/
|
|
14
|
+
internal object Bech32 {
|
|
15
|
+
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
16
|
+
|
|
17
|
+
private fun polymod(values: IntArray): Int {
|
|
18
|
+
val gen = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)
|
|
19
|
+
var chk = 1
|
|
20
|
+
for (v in values) {
|
|
21
|
+
val b = chk ushr 25
|
|
22
|
+
chk = (chk and 0x1ffffff) shl 5 xor v
|
|
23
|
+
for (i in 0 until 5) {
|
|
24
|
+
if ((b ushr i) and 1 == 1) chk = chk xor gen[i]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return chk
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private fun hrpExpand(hrp: String): IntArray {
|
|
31
|
+
val hi = hrp.map { it.code ushr 5 }
|
|
32
|
+
val lo = hrp.map { it.code and 31 }
|
|
33
|
+
return (hi + listOf(0) + lo).toIntArray()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private fun createChecksum(hrp: String, data: IntArray): IntArray {
|
|
37
|
+
val values = hrpExpand(hrp) + data + IntArray(6)
|
|
38
|
+
val mod = polymod(values) xor 1
|
|
39
|
+
return IntArray(6) { (mod ushr (5 * (5 - it))) and 31 }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 8-bit bytes -> 5-bit groups (BIP-173 "convertbits", 8→5, with padding). */
|
|
43
|
+
private fun convertBits8to5(data: ByteArray): IntArray {
|
|
44
|
+
var acc = 0
|
|
45
|
+
var bits = 0
|
|
46
|
+
val out = mutableListOf<Int>()
|
|
47
|
+
for (b in data) {
|
|
48
|
+
acc = (acc shl 8) or (b.toInt() and 0xff)
|
|
49
|
+
bits += 8
|
|
50
|
+
while (bits >= 5) {
|
|
51
|
+
bits -= 5
|
|
52
|
+
out.add((acc ushr bits) and 0x1f)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (bits > 0) out.add((acc shl (5 - bits)) and 0x1f)
|
|
56
|
+
return out.toIntArray()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Encodes a witness-version-0 program (20 bytes for P2WPKH, 32 for P2WSH) as a lowercase
|
|
60
|
+
* bech32 segwit address under `hrp`. */
|
|
61
|
+
fun encodeSegwitV0(hrp: String, program: ByteArray): String {
|
|
62
|
+
val data = intArrayOf(0) + convertBits8to5(program)
|
|
63
|
+
val combined = data + createChecksum(hrp, data)
|
|
64
|
+
val sb = StringBuilder(hrp).append('1')
|
|
65
|
+
for (d in combined) sb.append(CHARSET[d])
|
|
66
|
+
return sb.toString()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,526 @@
|
|
|
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.Ethereum
|
|
19
|
+
import wallet.core.jni.proto.Ripple
|
|
20
|
+
import wallet.core.jni.proto.Solana
|
|
21
|
+
import wallet.core.jni.proto.TheOpenNetwork
|
|
22
|
+
import wallet.core.jni.proto.Tron
|
|
23
|
+
import java.math.BigInteger
|
|
24
|
+
|
|
25
|
+
class ChainSigningException(message: String) : Exception(message)
|
|
26
|
+
|
|
27
|
+
// Gated on BuildConfig.DEBUG (false in release builds) rather than a bare Log.d call: UTXO
|
|
28
|
+
// txids/amounts/addresses/fees are financial metadata, and android.util.Log.d writes to logcat
|
|
29
|
+
// unconditionally — there's no default ProGuard/R8 rule stripping it, so an ungated call would
|
|
30
|
+
// genuinely ship in production, not just debug. `msg` is a lambda so the (string-templated)
|
|
31
|
+
// message is never even built in release, not just skipped on write.
|
|
32
|
+
private inline fun debugLog(msg: () -> String) {
|
|
33
|
+
if (BuildConfig.DEBUG) Log.d("ChainSigning", msg())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// All chains this module derives addresses for / signs transactions for.
|
|
37
|
+
enum class ChainKey(val coinType: CoinType) {
|
|
38
|
+
ETHEREUM(CoinType.ETHEREUM),
|
|
39
|
+
BNB(CoinType.SMARTCHAIN),
|
|
40
|
+
// All EVM chains share Ethereum's secp256k1 key/address (BIP44 slip44 = 60) — no distinct CoinType.
|
|
41
|
+
POLYGON(CoinType.ETHEREUM),
|
|
42
|
+
AVAX(CoinType.ETHEREUM),
|
|
43
|
+
BASE(CoinType.ETHEREUM),
|
|
44
|
+
ARBITRUM(CoinType.ETHEREUM),
|
|
45
|
+
OPTIMISM(CoinType.ETHEREUM),
|
|
46
|
+
SONIC(CoinType.ETHEREUM),
|
|
47
|
+
SOLANA(CoinType.SOLANA),
|
|
48
|
+
TRON(CoinType.TRON),
|
|
49
|
+
TON(CoinType.TON),
|
|
50
|
+
BITCOIN(CoinType.BITCOIN),
|
|
51
|
+
BITCOINCASH(CoinType.BITCOINCASH),
|
|
52
|
+
DOGECOIN(CoinType.DOGECOIN),
|
|
53
|
+
LITECOIN(CoinType.LITECOIN),
|
|
54
|
+
XRP(CoinType.XRP);
|
|
55
|
+
|
|
56
|
+
val symbol: String get() = when (this) {
|
|
57
|
+
ETHEREUM -> "ETH"; BNB -> "BNB"; POLYGON -> "POL"
|
|
58
|
+
AVAX -> "AVAX"; BASE -> "ETH"; ARBITRUM -> "ETH"; OPTIMISM -> "ETH"; SONIC -> "S"
|
|
59
|
+
SOLANA -> "SOL"; TRON -> "TRX"; TON -> "TON"
|
|
60
|
+
BITCOIN -> "BTC"; BITCOINCASH -> "BCH"; DOGECOIN -> "DOGE"; LITECOIN -> "LTC"; XRP -> "XRP"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
companion object {
|
|
64
|
+
fun fromJs(raw: String): ChainKey =
|
|
65
|
+
entries.find { it.name.equals(raw, ignoreCase = true) }
|
|
66
|
+
?: throw ChainSigningException("Unsupported chain: $raw")
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
data class ChainSignResult(val signedTx: String, val meta: Map<String, Any>?)
|
|
71
|
+
|
|
72
|
+
object ChainSigner {
|
|
73
|
+
// SLIP-44 dedicates coin_type 1' to "testnet" for every coin — so a shared literal path
|
|
74
|
+
// would make Litecoin and Bitcoin Cash derive the *same* key (BIP32 derivation only depends
|
|
75
|
+
// on (seed, path, curve), and CoinType alone doesn't perturb it when the path and curve
|
|
76
|
+
// — secp256k1 for both — are identical). Disambiguate by using each coin's own SLIP-44
|
|
77
|
+
// index as the account (3rd) path component. `purpose` follows the usual BIP44/49/84
|
|
78
|
+
// convention (44' legacy, 84' native segwit) matching the address style each coin actually
|
|
79
|
+
// gets below.
|
|
80
|
+
private fun utxoTestnetPath(chain: ChainKey, purpose: Int): String =
|
|
81
|
+
"m/$purpose'/1'/${chain.coinType.slip44Id()}'/0/0"
|
|
82
|
+
|
|
83
|
+
private const val LITECOIN_TESTNET_HRP = "tltc"
|
|
84
|
+
|
|
85
|
+
// Bitcoin Cash testnet legacy P2PKH version byte (0x6F) — same value Bitcoin/Litecoin
|
|
86
|
+
// testnets use for their base58 legacy prefix. BCH has no bech32/cashaddr testnet support in
|
|
87
|
+
// wallet-core (cashaddr is a different, more involved encoding than bech32 — unlike
|
|
88
|
+
// Litecoin below, not reimplemented here), so it stays on this legacy fallback.
|
|
89
|
+
private const val BCH_TESTNET_P2PKH_PREFIX: Byte = 0x6F
|
|
90
|
+
|
|
91
|
+
/** Address for `chain`, honoring `isTestnet`.
|
|
92
|
+
*
|
|
93
|
+
* wallet-core's coin registry only carries a real testnet derivation for Bitcoin
|
|
94
|
+
* (`Derivation.BITCOINTESTNET`, native segwit — same "bc1"→"tb1" style shift as mainnet).
|
|
95
|
+
* Litecoin and Bitcoin Cash have no testnet entry at all (no CoinType, no Derivation):
|
|
96
|
+
* - Litecoin gets a hand-rolled native-segwit bech32 address (see Bech32.kt) — the same
|
|
97
|
+
* style as its own mainnet "ltc1..." address, just hrp "tltc" instead of "ltc". wallet-
|
|
98
|
+
* core's `SegwitAddress` can't do this itself (its HRP is a closed native enum with no
|
|
99
|
+
* "tltc" entry), so this reimplements the encode half of BIP-173 by hand.
|
|
100
|
+
* - Bitcoin Cash gets a legacy P2PKH address instead — a different *style* from its own
|
|
101
|
+
* mainnet cashaddr address (cashaddr testnet isn't implemented), but still a real,
|
|
102
|
+
* correctly-testnet-flagged one.
|
|
103
|
+
*/
|
|
104
|
+
fun addressForChain(wallet: HDWallet, chain: ChainKey, isTestnet: Boolean): String {
|
|
105
|
+
if (!isTestnet) return wallet.getAddressForCoin(chain.coinType)
|
|
106
|
+
return when (chain) {
|
|
107
|
+
ChainKey.BITCOIN -> wallet.getAddressDerivation(CoinType.BITCOIN, Derivation.BITCOINTESTNET)
|
|
108
|
+
ChainKey.LITECOIN -> {
|
|
109
|
+
val pubKey = keyForChain(wallet, chain, isTestnet = true).getPublicKeySecp256k1(true)
|
|
110
|
+
val program = Hash.sha256RIPEMD(pubKey.data())
|
|
111
|
+
Bech32.encodeSegwitV0(LITECOIN_TESTNET_HRP, program)
|
|
112
|
+
}
|
|
113
|
+
ChainKey.BITCOINCASH -> {
|
|
114
|
+
val pubKey = keyForChain(wallet, chain, isTestnet = true).getPublicKeySecp256k1(true)
|
|
115
|
+
BitcoinAddress(pubKey, BCH_TESTNET_P2PKH_PREFIX).description()
|
|
116
|
+
}
|
|
117
|
+
// Everything else (EVM/Solana/Tron/Ton/Xrp) shares one address format across
|
|
118
|
+
// mainnet/testnet — only the RPC endpoint differs, which lives entirely in JS.
|
|
119
|
+
else -> wallet.getAddressForCoin(chain.coinType)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The signing key for `chain`, honoring `isTestnet` — must always derive the same key
|
|
124
|
+
* `addressForChain` used, or a UTXO signer builds a transaction that can't spend the
|
|
125
|
+
* wallet's own funds (wrong key ⇒ different scriptPubKey than what's actually sitting at
|
|
126
|
+
* the receive address it was given). */
|
|
127
|
+
private fun keyForChain(wallet: HDWallet, chain: ChainKey, isTestnet: Boolean): PrivateKey {
|
|
128
|
+
if (!isTestnet) return wallet.getKeyForCoin(chain.coinType)
|
|
129
|
+
return when (chain) {
|
|
130
|
+
ChainKey.BITCOIN -> wallet.getKeyDerivation(CoinType.BITCOIN, Derivation.BITCOINTESTNET)
|
|
131
|
+
ChainKey.LITECOIN -> wallet.getKey(CoinType.LITECOIN, utxoTestnetPath(chain, purpose = 84))
|
|
132
|
+
ChainKey.BITCOINCASH -> wallet.getKey(CoinType.BITCOINCASH, utxoTestnetPath(chain, purpose = 44))
|
|
133
|
+
else -> wallet.getKeyForCoin(chain.coinType)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fun sign(chain: ChainKey, wallet: HDWallet, unsignedTx: Map<String, Any>, isTestnet: Boolean): ChainSignResult = when (chain) {
|
|
138
|
+
ChainKey.ETHEREUM, ChainKey.BNB, ChainKey.POLYGON,
|
|
139
|
+
ChainKey.AVAX, ChainKey.BASE, ChainKey.ARBITRUM, ChainKey.OPTIMISM, ChainKey.SONIC ->
|
|
140
|
+
ChainSignResult(signEvm(wallet, chain.coinType, unsignedTx), null)
|
|
141
|
+
ChainKey.SOLANA -> ChainSignResult(signSolana(wallet, unsignedTx), null)
|
|
142
|
+
ChainKey.BITCOIN, ChainKey.DOGECOIN, ChainKey.LITECOIN -> ChainSignResult(signUtxo(wallet, chain, unsignedTx, isTestnet), null)
|
|
143
|
+
ChainKey.TRON -> ChainSignResult(signTron(wallet, unsignedTx), null)
|
|
144
|
+
ChainKey.XRP -> ChainSignResult(signXrp(wallet, unsignedTx), null)
|
|
145
|
+
ChainKey.TON -> signTon(wallet, unsignedTx)
|
|
146
|
+
ChainKey.BITCOINCASH -> ChainSignResult(signBch(wallet, unsignedTx), null)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// MARK: - EVM (ethereum / bnb / polygon)
|
|
150
|
+
// unsignedTx: { to, chainId, nonce, gasLimitHex, valueHex?, dataHex?,
|
|
151
|
+
// gasPriceHex? (legacy) | maxFeePerGasHex + maxPriorityFeePerGasHex (EIP-1559) }
|
|
152
|
+
|
|
153
|
+
private fun signEvm(wallet: HDWallet, coin: CoinType, unsignedTx: Map<String, Any>): String {
|
|
154
|
+
val privateKey = wallet.getKeyForCoin(coin)
|
|
155
|
+
val to = unsignedTx["to"] as? String ?: throw ChainSigningException("Missing to")
|
|
156
|
+
val nonce = (unsignedTx["nonce"] as? Number)?.toLong() ?: throw ChainSigningException("Missing nonce")
|
|
157
|
+
val gasLimHex = unsignedTx["gasLimitHex"] as? String ?: throw ChainSigningException("Missing gasLimitHex")
|
|
158
|
+
val chainId = (unsignedTx["chainId"] as? Number)?.toLong() ?: throw ChainSigningException("Missing chainId")
|
|
159
|
+
val valueHex = (unsignedTx["valueHex"] as? String)?.ifEmpty { "0" } ?: "0"
|
|
160
|
+
val dataHex = unsignedTx["dataHex"] as? String ?: ""
|
|
161
|
+
|
|
162
|
+
val input = Ethereum.SigningInput.newBuilder().apply {
|
|
163
|
+
this.chainId = BigInteger.valueOf(chainId).toMinimalByteString()
|
|
164
|
+
this.nonce = BigInteger.valueOf(nonce).toMinimalByteString()
|
|
165
|
+
this.gasLimit = BigInteger(gasLimHex, 16).toMinimalByteString()
|
|
166
|
+
this.toAddress = to
|
|
167
|
+
this.privateKey = ByteString.copyFrom(privateKey.data())
|
|
168
|
+
|
|
169
|
+
this.transaction = Ethereum.Transaction.newBuilder().apply {
|
|
170
|
+
this.transfer = Ethereum.Transaction.Transfer.newBuilder().apply {
|
|
171
|
+
this.amount = BigInteger(valueHex, 16).toMinimalByteString()
|
|
172
|
+
if (dataHex.isNotEmpty()) this.data = ByteString.copyFrom(dataHex.hexToBytes())
|
|
173
|
+
}.build()
|
|
174
|
+
}.build()
|
|
175
|
+
|
|
176
|
+
val gasPriceHex = unsignedTx["gasPriceHex"] as? String
|
|
177
|
+
if (gasPriceHex != null) {
|
|
178
|
+
this.gasPrice = BigInteger(gasPriceHex, 16).toMinimalByteString()
|
|
179
|
+
} else {
|
|
180
|
+
val mfHex = unsignedTx["maxFeePerGasHex"] as? String
|
|
181
|
+
val pfHex = unsignedTx["maxPriorityFeePerGasHex"] as? String
|
|
182
|
+
if (mfHex != null && pfHex != null) {
|
|
183
|
+
this.txMode = Ethereum.TransactionMode.Enveloped
|
|
184
|
+
this.maxFeePerGas = BigInteger(mfHex, 16).toMinimalByteString()
|
|
185
|
+
this.maxInclusionFeePerGas = BigInteger(pfHex, 16).toMinimalByteString()
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}.build()
|
|
189
|
+
|
|
190
|
+
val output = AnySigner.sign(input, coin, Ethereum.SigningOutput.parser())
|
|
191
|
+
if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
|
|
192
|
+
return "0x" + output.encoded.toByteArray().toHex()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// MARK: - Solana
|
|
196
|
+
// unsignedTx: { unsignedTxBase64: string } — a fully-built, base64-serialized unsigned
|
|
197
|
+
// @solana/web3.js Transaction (native transfer or SPL-token transfer) from
|
|
198
|
+
// prepareSelfCustodyUnsignedTx's SOL branch, already carrying a recentBlockhash/feePayer.
|
|
199
|
+
// wallet-core has no "sign these raw bytes as-is" entry point for Solana — RawMessage is a
|
|
200
|
+
// structured legacy/v0 message, not an opaque blob. `updateBlockhashAndSign` is TW's own
|
|
201
|
+
// helper for signing an already-built web3.js transaction: it takes the base64 tx, a
|
|
202
|
+
// recent blockhash, and a set of private keys, and re-serializes+signs. We don't have a
|
|
203
|
+
// fresher blockhash than the one already embedded, so decode the tx to read it back out
|
|
204
|
+
// (via TransactionDecoder) and hand it straight back in — a no-op "update" that still goes
|
|
205
|
+
// through the sanctioned signing path.
|
|
206
|
+
private fun signSolana(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
|
|
207
|
+
val privateKey = wallet.getKeyForCoin(CoinType.SOLANA)
|
|
208
|
+
val unsignedTxBase64 = unsignedTx["unsignedTxBase64"] as? String
|
|
209
|
+
?: throw ChainSigningException("Missing unsignedTxBase64")
|
|
210
|
+
val txBytes = android.util.Base64.decode(unsignedTxBase64, android.util.Base64.NO_WRAP)
|
|
211
|
+
|
|
212
|
+
val decoded = Solana.DecodingTransactionOutput.parseFrom(TransactionDecoder.decode(CoinType.SOLANA, txBytes))
|
|
213
|
+
if (decoded.error != Common.SigningError.OK) {
|
|
214
|
+
throw ChainSigningException("Failed to decode Solana tx: ${decoded.errorMessage}")
|
|
215
|
+
}
|
|
216
|
+
val recentBlockhash = decoded.transaction.legacy.recentBlockhash
|
|
217
|
+
|
|
218
|
+
val privateKeys = DataVector()
|
|
219
|
+
privateKeys.add(privateKey.data())
|
|
220
|
+
|
|
221
|
+
val output = Solana.SigningOutput.parseFrom(
|
|
222
|
+
SolanaTransaction.updateBlockhashAndSign(unsignedTxBase64, recentBlockhash, privateKeys)
|
|
223
|
+
)
|
|
224
|
+
if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
|
|
225
|
+
return output.encoded
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// MARK: - BCH (UTXO-based, replay-protected)
|
|
229
|
+
// unsignedTx: { unsignedDescriptorJson: string }
|
|
230
|
+
// descriptor (from wallet-broadcast's prepareBchTransaction):
|
|
231
|
+
// { inputs: [{ txid, vout, satoshis, scriptPubKeyHex }], toAddress, sendAmountSats,
|
|
232
|
+
// changeAddress?, changeSats? }
|
|
233
|
+
// BCH uses SIGHASH_ALL | SIGHASH_FORK_ID (0x41) for replay protection.
|
|
234
|
+
private fun signBch(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
|
|
235
|
+
val descriptorJson = unsignedTx["unsignedDescriptorJson"] as? String
|
|
236
|
+
?: throw ChainSigningException("Missing unsignedDescriptorJson for BCH")
|
|
237
|
+
|
|
238
|
+
val descriptor = JSONObject(descriptorJson)
|
|
239
|
+
val toAddress = descriptor.getString("toAddress")
|
|
240
|
+
val sendAmountSats = descriptor.getLong("sendAmountSats")
|
|
241
|
+
val changeAddress = if (descriptor.has("changeAddress")) descriptor.getString("changeAddress") else null
|
|
242
|
+
val inputsJson = descriptor.getJSONArray("inputs")
|
|
243
|
+
|
|
244
|
+
val privateKey = wallet.getKeyForCoin(CoinType.BITCOINCASH)
|
|
245
|
+
|
|
246
|
+
val utxos = (0 until inputsJson.length()).map { i ->
|
|
247
|
+
val entry = inputsJson.getJSONObject(i)
|
|
248
|
+
val txIdHex = entry.getString("txid")
|
|
249
|
+
val vout = entry.getInt("vout")
|
|
250
|
+
val satoshis = entry.getLong("satoshis")
|
|
251
|
+
val scriptHex = entry.getString("scriptPubKeyHex")
|
|
252
|
+
val txIdBytes = txIdHex.hexToBytes().reversedArray()
|
|
253
|
+
|
|
254
|
+
Bitcoin.UnspentTransaction.newBuilder().apply {
|
|
255
|
+
this.outPoint = Bitcoin.OutPoint.newBuilder().apply {
|
|
256
|
+
this.hash = ByteString.copyFrom(txIdBytes)
|
|
257
|
+
this.index = vout
|
|
258
|
+
}.build()
|
|
259
|
+
this.amount = satoshis
|
|
260
|
+
this.script = ByteString.copyFrom(scriptHex.hexToBytes())
|
|
261
|
+
}.build()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
val input = Bitcoin.SigningInput.newBuilder().apply {
|
|
265
|
+
this.hashType = 0x41 // SIGHASH_ALL | SIGHASH_FORK_ID (BCH replay protection)
|
|
266
|
+
this.amount = sendAmountSats
|
|
267
|
+
this.byteFee = 1 // BCH fees are minimal; 1 sat/byte
|
|
268
|
+
this.toAddress = toAddress
|
|
269
|
+
if (changeAddress != null) this.changeAddress = changeAddress
|
|
270
|
+
this.useMaxAmount = false
|
|
271
|
+
this.coinType = CoinType.BITCOINCASH.value()
|
|
272
|
+
this.addPrivateKey(ByteString.copyFrom(privateKey.data()))
|
|
273
|
+
this.addAllUtxo(utxos)
|
|
274
|
+
}.build()
|
|
275
|
+
|
|
276
|
+
val output = AnySigner.sign(input, CoinType.BITCOINCASH, Bitcoin.SigningOutput.parser())
|
|
277
|
+
if (output.error != Common.SigningError.OK) throw ChainSigningException("BCH signing failed: ${output.errorMessage}")
|
|
278
|
+
return output.encoded.toByteArray().toHex()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// MARK: - BTC / LTC (UTXO-based)
|
|
282
|
+
// unsignedTx (from wallet-broadcast's additive prepareBtcUtxoSet/prepareLtcUtxoSet):
|
|
283
|
+
// { toAddress, changeAddress, sendAmountSats, changeAmountSats, satsPerByte,
|
|
284
|
+
// inputs: [{ txIdHex, vout, amountSats, scriptPubKeyHex }] }
|
|
285
|
+
@Suppress("UNCHECKED_CAST")
|
|
286
|
+
private fun signUtxo(wallet: HDWallet, chain: ChainKey, unsignedTx: Map<String, Any>, isTestnet: Boolean): String {
|
|
287
|
+
val coin = chain.coinType
|
|
288
|
+
val privateKey = keyForChain(wallet, chain, isTestnet)
|
|
289
|
+
val toAddress = unsignedTx["toAddress"] as? String ?: throw ChainSigningException("Missing toAddress")
|
|
290
|
+
val changeAddress = unsignedTx["changeAddress"] as? String ?: throw ChainSigningException("Missing changeAddress")
|
|
291
|
+
val sendAmountSats = (unsignedTx["sendAmountSats"] as? String)?.toLong() ?: throw ChainSigningException("Missing sendAmountSats")
|
|
292
|
+
val satsPerByte = (unsignedTx["satsPerByte"] as? Number)?.toLong() ?: throw ChainSigningException("Missing satsPerByte")
|
|
293
|
+
val inputs = unsignedTx["inputs"] as? List<Map<String, Any>> ?: throw ChainSigningException("Missing inputs")
|
|
294
|
+
|
|
295
|
+
debugLog { "signUtxo: parsing ${inputs.size} UTXOs" }
|
|
296
|
+
val utxos = inputs.map { entry ->
|
|
297
|
+
val txIdHex = entry["txIdHex"] as? String ?: throw ChainSigningException("Invalid UTXO entry: missing txIdHex, keys=${entry.keys}")
|
|
298
|
+
val vout = (entry["vout"] as? Number)?.toInt() ?: throw ChainSigningException("Invalid UTXO entry: missing vout")
|
|
299
|
+
val amount = (entry["amountSats"] as? String)?.toLong() ?: throw ChainSigningException("Invalid UTXO entry: missing amountSats, type=${entry["amountSats"]?.javaClass?.name}")
|
|
300
|
+
val scriptHex = entry["scriptPubKeyHex"] as? String ?: throw ChainSigningException("Invalid UTXO entry: missing scriptPubKeyHex")
|
|
301
|
+
debugLog { "signUtxo: UTXO txid=$txIdHex vout=$vout amount=$amount" }
|
|
302
|
+
|
|
303
|
+
// On-chain/explorer txid hex is displayed big-endian; wallet-core's OutPoint.hash wants
|
|
304
|
+
// the reversed (little-endian, internal wire-format) byte order.
|
|
305
|
+
val txIdBytes = txIdHex.hexToBytes().reversedArray()
|
|
306
|
+
|
|
307
|
+
Bitcoin.UnspentTransaction.newBuilder().apply {
|
|
308
|
+
this.outPoint = Bitcoin.OutPoint.newBuilder().apply {
|
|
309
|
+
this.hash = ByteString.copyFrom(txIdBytes)
|
|
310
|
+
this.index = vout
|
|
311
|
+
}.build()
|
|
312
|
+
this.amount = amount
|
|
313
|
+
this.script = ByteString.copyFrom(scriptHex.hexToBytes())
|
|
314
|
+
}.build()
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
debugLog { "signUtxo: building SigningInput toAddress=$toAddress sats=$sendAmountSats fee=$satsPerByte" }
|
|
318
|
+
val input = Bitcoin.SigningInput.newBuilder().apply {
|
|
319
|
+
this.hashType = 1 // SIGHASH_ALL — stable Bitcoin protocol constant, not a wallet-core-specific value
|
|
320
|
+
this.amount = sendAmountSats
|
|
321
|
+
this.byteFee = satsPerByte
|
|
322
|
+
this.toAddress = toAddress
|
|
323
|
+
this.changeAddress = changeAddress
|
|
324
|
+
this.useMaxAmount = false
|
|
325
|
+
this.coinType = coin.value()
|
|
326
|
+
this.addPrivateKey(ByteString.copyFrom(privateKey.data()))
|
|
327
|
+
this.addAllUtxo(utxos)
|
|
328
|
+
}.build()
|
|
329
|
+
|
|
330
|
+
debugLog { "signUtxo: calling AnySigner.sign coin=${coin.name}" }
|
|
331
|
+
val output = AnySigner.sign(input, coin, Bitcoin.SigningOutput.parser())
|
|
332
|
+
debugLog { "signUtxo: AnySigner.sign done error=${output.error} msg=${output.errorMessage}" }
|
|
333
|
+
if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
|
|
334
|
+
return output.encoded.toByteArray().toHex()
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// MARK: - TRX
|
|
338
|
+
// unsignedTx: the raw TronGrid-shaped unsigned tx object from prepareSelfCustodyUnsignedTx's
|
|
339
|
+
// TRX branch (TronWeb's transactionBuilder output — raw_data, raw_data_hex, txID). Building a
|
|
340
|
+
// Tron.Transaction proto from scratch would need the *full* source BlockHeader (parent hash,
|
|
341
|
+
// tx trie root, witness address, version): wallet-core hashes that header itself to derive
|
|
342
|
+
// ref_block_bytes/ref_block_hash, and TronGrid only ever hands us those two derived fields
|
|
343
|
+
// pre-computed, not the header they came from — so there's no way to reconstruct one that
|
|
344
|
+
// rehashes to the same values. Tron.SigningInput's `txId` field exists for exactly this case
|
|
345
|
+
// (see its proto doc: "direct sign in Tron, we just have to sign the txId returned by the
|
|
346
|
+
// DApp json payload") — it signs the given digest as-is and skips transaction rebuilding
|
|
347
|
+
// entirely, which also means TRC20 `triggerSmartContract` payloads are covered for free, not
|
|
348
|
+
// just plain transfers. wallet-core's direct-sign path only returns the raw signature (no
|
|
349
|
+
// `json`), so the signed tx is assembled here the same way TronWeb does: original tx + a
|
|
350
|
+
// `signature` array — the shape broadcastTrxTransaction's tronWeb.trx.sendRawTransaction expects.
|
|
351
|
+
private fun signTron(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
|
|
352
|
+
val privateKey = wallet.getKeyForCoin(CoinType.TRON)
|
|
353
|
+
val txId = unsignedTx["txID"] as? String ?: throw ChainSigningException("Missing txID")
|
|
354
|
+
|
|
355
|
+
val input = Tron.SigningInput.newBuilder().apply {
|
|
356
|
+
this.txId = txId
|
|
357
|
+
this.privateKey = ByteString.copyFrom(privateKey.data())
|
|
358
|
+
}.build()
|
|
359
|
+
|
|
360
|
+
val output = AnySigner.sign(input, CoinType.TRON, Tron.SigningOutput.parser())
|
|
361
|
+
if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
|
|
362
|
+
|
|
363
|
+
val signatureHex = output.signature.toByteArray().toHex()
|
|
364
|
+
val signedTx = JSONObject(unsignedTx).put("signature", org.json.JSONArray().put(signatureHex))
|
|
365
|
+
return signedTx.toString()
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// MARK: - XRP
|
|
369
|
+
// unsignedTx: xrpl.js `Payment` object (Account, Destination, Amount (drops, string),
|
|
370
|
+
// Fee (drops, string), Sequence, LastLedgerSequence, DestinationTag?).
|
|
371
|
+
private fun signXrp(wallet: HDWallet, unsignedTx: Map<String, Any>): String {
|
|
372
|
+
val privateKey = wallet.getKeyForCoin(CoinType.XRP)
|
|
373
|
+
val account = unsignedTx["Account"] as? String ?: throw ChainSigningException("Missing Account")
|
|
374
|
+
val destination = unsignedTx["Destination"] as? String ?: throw ChainSigningException("Missing Destination")
|
|
375
|
+
val amountDrops = unsignedTx["Amount"] as? String ?: throw ChainSigningException("Missing Amount")
|
|
376
|
+
val feeDrops = unsignedTx["Fee"] as? String ?: throw ChainSigningException("Missing Fee")
|
|
377
|
+
val sequence = (unsignedTx["Sequence"] as? Number)?.toInt() ?: throw ChainSigningException("Missing Sequence")
|
|
378
|
+
val lastLedgerSequence = (unsignedTx["LastLedgerSequence"] as? Number)?.toInt()
|
|
379
|
+
val destinationTag = parseXrpDestinationTag((unsignedTx["DestinationTag"] as? Number)?.toLong())
|
|
380
|
+
|
|
381
|
+
val input = Ripple.SigningInput.newBuilder().apply {
|
|
382
|
+
this.privateKey = ByteString.copyFrom(privateKey.data())
|
|
383
|
+
this.account = account
|
|
384
|
+
this.fee = parseXrpFeeDrops(feeDrops)
|
|
385
|
+
this.sequence = sequence
|
|
386
|
+
lastLedgerSequence?.let { this.lastLedgerSequence = it }
|
|
387
|
+
this.opPayment = Ripple.OperationPayment.newBuilder().apply {
|
|
388
|
+
this.amount = parseXrpAmountDrops(amountDrops)
|
|
389
|
+
this.destination = destination
|
|
390
|
+
destinationTag?.let { this.destinationTag = it }
|
|
391
|
+
}.build()
|
|
392
|
+
}.build()
|
|
393
|
+
|
|
394
|
+
val output = AnySigner.sign(input, CoinType.XRP, Ripple.SigningOutput.parser())
|
|
395
|
+
if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
|
|
396
|
+
return output.encoded.toByteArray().toHex()
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// MARK: - TON
|
|
400
|
+
// unsignedTx: { toAddress, amount, seqno, memoId? }.
|
|
401
|
+
// NOTE(verify-on-device): confirm `amount` units (nanoton vs TON) and that wallet_version
|
|
402
|
+
// V4R2 matches the address format the current @ton/* implementation derives.
|
|
403
|
+
// expireAt isn't supplied by prepareSelfCustodyUnsignedTx's TON branch (just
|
|
404
|
+
// {toAddress,amount,seqno,memoId}); default to a 10-minute signing window, matching the
|
|
405
|
+
// extendExpiration(tx, 600) buffer TRX's prepare step already uses.
|
|
406
|
+
private fun signTon(wallet: HDWallet, unsignedTx: Map<String, Any>): ChainSignResult {
|
|
407
|
+
val privateKey = wallet.getKeyForCoin(CoinType.TON)
|
|
408
|
+
val toAddress = unsignedTx["toAddress"] as? String ?: throw ChainSigningException("Missing toAddress")
|
|
409
|
+
val amountStr = unsignedTx["amount"] as? String ?: throw ChainSigningException("Missing amount")
|
|
410
|
+
val amount = parseTonNanotons(amountStr)
|
|
411
|
+
val seqno = (unsignedTx["seqno"] as? Number)?.toInt() ?: throw ChainSigningException("Missing seqno")
|
|
412
|
+
val memoId = unsignedTx["memoId"] as? String
|
|
413
|
+
val expireAt = (System.currentTimeMillis() / 1000L + 600L).toInt()
|
|
414
|
+
|
|
415
|
+
val transfer = TheOpenNetwork.Transfer.newBuilder().apply {
|
|
416
|
+
this.dest = toAddress
|
|
417
|
+
this.amount = amount
|
|
418
|
+
this.mode = TheOpenNetwork.SendMode.PAY_FEES_SEPARATELY_VALUE or TheOpenNetwork.SendMode.IGNORE_ACTION_PHASE_ERRORS_VALUE
|
|
419
|
+
this.bounceable = true
|
|
420
|
+
memoId?.let { this.comment = it }
|
|
421
|
+
}.build()
|
|
422
|
+
|
|
423
|
+
val input = TheOpenNetwork.SigningInput.newBuilder().apply {
|
|
424
|
+
this.privateKey = ByteString.copyFrom(privateKey.data())
|
|
425
|
+
this.walletVersion = TheOpenNetwork.WalletVersion.WALLET_V4_R2
|
|
426
|
+
this.sequenceNumber = seqno
|
|
427
|
+
this.expireAt = expireAt
|
|
428
|
+
this.addMessages(transfer)
|
|
429
|
+
}.build()
|
|
430
|
+
|
|
431
|
+
val output = AnySigner.sign(input, CoinType.TON, TheOpenNetwork.SigningOutput.parser())
|
|
432
|
+
if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
|
|
433
|
+
return ChainSignResult(output.encoded, mapOf("txHash" to output.hash.toByteArray().toHex()))
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Transaction summary helpers (used by ChainberryTrustWalletCoreModule for native confirmation UI)
|
|
438
|
+
|
|
439
|
+
internal fun ChainSigner.buildSummary(chain: ChainKey, unsignedTx: Map<String, Any>): String {
|
|
440
|
+
val lines = mutableListOf("Network: ${chain.name}")
|
|
441
|
+
when (chain) {
|
|
442
|
+
ChainKey.ETHEREUM, ChainKey.BNB, ChainKey.POLYGON,
|
|
443
|
+
ChainKey.AVAX, ChainKey.BASE, ChainKey.ARBITRUM, ChainKey.OPTIMISM, ChainKey.SONIC -> {
|
|
444
|
+
(unsignedTx["to"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
|
|
445
|
+
val valueWei = txHexToDouble((unsignedTx["valueHex"] as? String) ?: "0")
|
|
446
|
+
lines += "Amount: ${fmtAmt(valueWei / 1e18)} ${chain.symbol}"
|
|
447
|
+
val gasLimit = txHexToDouble((unsignedTx["gasLimitHex"] as? String) ?: "0")
|
|
448
|
+
val gasPrice = txHexToDouble(
|
|
449
|
+
(unsignedTx["gasPriceHex"] as? String) ?: (unsignedTx["maxFeePerGasHex"] as? String) ?: "0"
|
|
450
|
+
)
|
|
451
|
+
val feeWei = gasLimit * gasPrice
|
|
452
|
+
if (feeWei > 0) lines += "Max fee: ${fmtAmt(feeWei / 1e18)} ${chain.symbol}"
|
|
453
|
+
}
|
|
454
|
+
ChainKey.BITCOIN, ChainKey.DOGECOIN, ChainKey.LITECOIN -> {
|
|
455
|
+
(unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
|
|
456
|
+
(unsignedTx["sendAmountSats"] as? String)?.toLongOrNull()?.let {
|
|
457
|
+
lines += "Amount: ${fmtAmt(it.toDouble() / 1e8)} ${chain.symbol}"
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
ChainKey.XRP -> {
|
|
461
|
+
(unsignedTx["Destination"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
|
|
462
|
+
(unsignedTx["Amount"] as? String)?.toLongOrNull()?.let {
|
|
463
|
+
lines += "Amount: ${fmtAmt(it.toDouble() / 1_000_000.0)} XRP"
|
|
464
|
+
}
|
|
465
|
+
(unsignedTx["Fee"] as? String)?.toLongOrNull()?.let {
|
|
466
|
+
lines += "Fee: ${fmtAmt(it.toDouble() / 1_000_000.0)} XRP"
|
|
467
|
+
}
|
|
468
|
+
unsignedTx["DestinationTag"]?.let { lines += "Tag: $it" }
|
|
469
|
+
}
|
|
470
|
+
ChainKey.TON -> {
|
|
471
|
+
(unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
|
|
472
|
+
(unsignedTx["amount"] as? String)?.toULongOrNull()?.let {
|
|
473
|
+
lines += "Amount: ${fmtAmt(it.toDouble() / 1e9)} TON"
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
ChainKey.TRON -> {
|
|
477
|
+
@Suppress("UNCHECKED_CAST")
|
|
478
|
+
val value = ((unsignedTx["raw_data"] as? Map<String, Any>)
|
|
479
|
+
?.let { (it["contract"] as? List<Map<String, Any>>)?.firstOrNull() }
|
|
480
|
+
?.let { it["parameter"] as? Map<String, Any> }
|
|
481
|
+
?.let { it["value"] as? Map<String, Any> })
|
|
482
|
+
value?.let { v ->
|
|
483
|
+
(v["to_address"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
|
|
484
|
+
(v["amount"] as? Number)?.let { lines += "Amount: ${fmtAmt(it.toDouble() / 1e6)} TRX" }
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
ChainKey.SOLANA -> lines += "(Solana — details verified by the network)"
|
|
488
|
+
ChainKey.BITCOINCASH -> {
|
|
489
|
+
try {
|
|
490
|
+
val descriptor = JSONObject((unsignedTx["unsignedDescriptorJson"] as? String) ?: "")
|
|
491
|
+
descriptor.optString("toAddress").takeIf { it.isNotEmpty() }?.let { lines += "To: ${fmtAddr(it)}" }
|
|
492
|
+
val sats = descriptor.optLong("sendAmountSats", -1L)
|
|
493
|
+
if (sats >= 0) lines += "Amount: ${fmtAmt(sats.toDouble() / 1e8)} BCH"
|
|
494
|
+
} catch (_: Exception) {}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return lines.joinToString("\n")
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private fun txHexToDouble(hex: String): Double = try {
|
|
501
|
+
BigInteger(hex.removePrefix("0x").ifEmpty { "0" }, 16).toDouble()
|
|
502
|
+
} catch (_: NumberFormatException) { 0.0 }
|
|
503
|
+
|
|
504
|
+
private fun fmtAmt(value: Double): String =
|
|
505
|
+
"%.8f".format(value).trimEnd('0').trimEnd('.')
|
|
506
|
+
|
|
507
|
+
private fun fmtAddr(addr: String): String = addr
|
|
508
|
+
|
|
509
|
+
// Helpers
|
|
510
|
+
|
|
511
|
+
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
|
512
|
+
|
|
513
|
+
private fun String.hexToBytes(): ByteArray {
|
|
514
|
+
val s = removePrefix("0x").let { if (it.length % 2 == 0) it else "0$it" }
|
|
515
|
+
return ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// BigInteger -> minimal big-endian ByteString (strips Java's leading sign byte)
|
|
519
|
+
private fun BigInteger.toMinimalByteString(): ByteString {
|
|
520
|
+
val raw = toByteArray()
|
|
521
|
+
return if (raw.size > 1 && raw[0] == 0.toByte()) {
|
|
522
|
+
ByteString.copyFrom(raw, 1, raw.size - 1)
|
|
523
|
+
} else {
|
|
524
|
+
ByteString.copyFrom(raw)
|
|
525
|
+
}
|
|
526
|
+
}
|