@chainberry/trust-wallet-core 2.5.0 → 2.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ChainberryTrustWalletCoreModule.podspec +9 -2
- package/README.md +42 -5
- package/android/src/androidTest/java/com/chainberry/trustwalletcore/AddressDerivationConformanceTest.kt +4 -4
- package/android/src/androidTest/java/com/chainberry/trustwalletcore/SigningConformanceTest.kt +2 -2
- package/android/src/main/java/com/chainberry/trustwalletcore/ChainSigning.kt +393 -18
- package/android/src/main/java/com/chainberry/trustwalletcore/ChainberryTrustWalletCoreModule.kt +101 -18
- package/android/src/main/java/com/chainberry/trustwalletcore/NativeWalletStore.kt +128 -18
- package/android/src/test/java/com/chainberry/trustwalletcore/NativeWalletStoreTest.kt +92 -4
- package/ios/ChainSigning.swift +430 -40
- package/ios/ChainberryTrustWalletCoreModule.swift +131 -20
- package/ios/ConformanceTests/AddressDerivationConformanceTests.swift +39 -0
- package/ios/ConformanceTests/SigningConformanceTests.swift +292 -0
- package/ios/NativeWalletStore.swift +73 -6
- package/package.json +1 -1
- package/src/index.ts +24 -10
|
@@ -10,12 +10,19 @@ Pod::Spec.new do |s|
|
|
|
10
10
|
s.homepage = 'https://github.com/Chainberry-com/trust-wallet-core'
|
|
11
11
|
s.license = package['license']
|
|
12
12
|
s.author = 'Chainberry'
|
|
13
|
-
s.platform = :ios, '
|
|
13
|
+
s.platform = :ios, '15.1'
|
|
14
14
|
s.source = { git: 'git@github.com:Chainberry-com/trust-wallet-core.git', tag: "v#{package['version']}" }
|
|
15
15
|
s.static_framework = true
|
|
16
16
|
|
|
17
|
-
s.source_files
|
|
17
|
+
s.source_files = 'ios/**/*.{h,m,mm,swift}'
|
|
18
|
+
s.exclude_files = 'ios/ConformanceTests/**'
|
|
18
19
|
|
|
19
20
|
s.dependency 'ExpoModulesCore'
|
|
20
21
|
s.dependency 'TrustWalletCore', '4.1.19'
|
|
22
|
+
|
|
23
|
+
s.test_spec 'ConformanceTests' do |ts|
|
|
24
|
+
ts.source_files = 'ios/ConformanceTests/*.swift'
|
|
25
|
+
ts.dependency 'TrustWalletCore', '4.1.19'
|
|
26
|
+
ts.resources = ['conformance/signing-vectors.json', 'conformance/address-derivation-vectors.json']
|
|
27
|
+
end
|
|
21
28
|
end
|
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Supported chains: Ethereum, BNB Smart Chain, Polygon, Solana, Tron, TON, Bitcoin
|
|
|
10
10
|
npx expo install @chainberry/trust-wallet-core
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
This is an Expo
|
|
13
|
+
This is an Expo native module with native Android/iOS code. It uses Expo's autolinking mechanism and does not ship an Expo config plugin. A development build is required (`expo prebuild` / EAS Build) — it will not work in Expo Go.
|
|
14
14
|
|
|
15
15
|
### Android: no GitHub credentials needed
|
|
16
16
|
|
|
@@ -24,10 +24,43 @@ GitHub Packages auth is still needed (via `GITHUB_ACTOR`/`GITHUB_TOKEN` env vars
|
|
|
24
24
|
|
|
25
25
|
Android biometric gating additionally pulls in `androidx.biometric:biometric:1.1.0`.
|
|
26
26
|
|
|
27
|
+
## Publish
|
|
28
|
+
|
|
29
|
+
This package is consumed by the app via `file:modules/trust-wallet-core` (see the root
|
|
30
|
+
`package.json`), not an npm workspace under `packages/*` — so, unlike
|
|
31
|
+
`@chainberry/expo-wallet-sdk`, there's no `-w` flag to use; run these from inside this directory.
|
|
32
|
+
There's also no build step: this package ships its TypeScript source directly (`main`/`types`
|
|
33
|
+
point at `src/index.ts`), so no `prepublishOnly` rebuild happens either.
|
|
34
|
+
|
|
35
|
+
1. Bump the version (in `package.json`, or via):
|
|
36
|
+
```sh
|
|
37
|
+
cd modules/trust-wallet-core
|
|
38
|
+
npm version patch # or minor / major
|
|
39
|
+
```
|
|
40
|
+
2. Dry-run first — prints exactly what would be published without touching the registry:
|
|
41
|
+
```sh
|
|
42
|
+
npm publish --dry-run
|
|
43
|
+
```
|
|
44
|
+
3. Publish for real:
|
|
45
|
+
```sh
|
|
46
|
+
npm publish
|
|
47
|
+
```
|
|
48
|
+
`publishConfig.access: "public"` in this package's own `package.json` already covers the
|
|
49
|
+
`--access public` flag scoped packages otherwise need.
|
|
50
|
+
|
|
51
|
+
Requires npm registry publish auth (an `_authToken` for `@chainberry`, e.g. via `.npmrc` — not
|
|
52
|
+
committed to git). This is separate from the GitHub Packages auth mentioned above, which is only
|
|
53
|
+
needed for bumping the pinned Android wallet-core artifact, not for publishing this npm package.
|
|
54
|
+
|
|
27
55
|
## Usage
|
|
28
56
|
|
|
29
57
|
```ts
|
|
30
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
createWallet,
|
|
60
|
+
importWallet,
|
|
61
|
+
signTransaction,
|
|
62
|
+
exportMnemonic,
|
|
63
|
+
} from "@chainberry/trust-wallet-core";
|
|
31
64
|
|
|
32
65
|
const { walletId, addresses } = await createWallet(); // 128-bit / 12-word by default
|
|
33
66
|
// addresses: { ethereum: "0x...", solana: "...", bnb: "0x...", bitcoin: "...", ... }
|
|
@@ -46,7 +79,7 @@ const mnemonic = await exportMnemonic(walletId);
|
|
|
46
79
|
- `createWallet(strength = 128)` — generates a new BIP-39 mnemonic and persists it natively (Keychain on iOS / Keystore-backed file on Android, biometry-or-passcode gated). Returns `{ walletId, addresses }` — the mnemonic itself never leaves native code. No BIP-39 passphrase support: `signTransaction` always reconstructs the wallet with an empty passphrase, so a caller-supplied one would derive addresses from a seed different from the one actually used to sign.
|
|
47
80
|
- `importWallet(mnemonic)` — validates and persists an existing mnemonic the same way. The `mnemonic` argument is a one-time exposure from the caller (e.g. a text-entry backup-restore screen); discard your own copy immediately after this call resolves.
|
|
48
81
|
- `listWallets()` — returns `{ walletId, addresses }[]` for every persisted wallet, reading only the ungated metadata store. No biometric prompt.
|
|
49
|
-
- `deleteWallet(walletId)` — removes the wallet's native key material and metadata entry. Irreversible;
|
|
82
|
+
- `deleteWallet(walletId)` — removes the wallet's native key material and metadata entry. Irreversible; requires a fresh biometric/passcode confirmation before deletion proceeds on both platforms.
|
|
50
83
|
- `signTransaction(walletId, chain, unsignedTx)` — triggers a native biometry/passcode prompt, then derives the key and signs entirely inside native code. Returns `{ signedTx, meta? }`; `meta` currently only carries TON's `txHash`.
|
|
51
84
|
- `exportMnemonic(walletId)` — the one sanctioned mnemonic exposure. Biometry/passcode gated. Use only for an explicit "reveal recovery phrase" backup screen; don't hold the result in app state beyond that screen's lifetime.
|
|
52
85
|
|
|
@@ -64,9 +97,13 @@ Both platforms pin **Trust Wallet Core 4.1.19** (`com.trustwallet:wallet-core:4.
|
|
|
64
97
|
|
|
65
98
|
**Address derivation is verified for all 10 chains** in `conformance/address-derivation-vectors.json`, asserted by the Android instrumented test (`src/androidTest/.../AddressDerivationConformanceTest.kt`; run via `./gradlew connectedDebugAndroidTest`). Methodology: real on-device 4.1.19 addresses were harvested from the instrumented test running on an emulator, cross-checked against an independent derivation via the WASM build (`@trustwallet/wallet-core` 3.3.3, run standalone in Node — a different upstream release than the pinned 4.1.19, used only as a second data point); on-device 4.1.19 is authoritative wherever the two disagree, since that's what the app actually ships. They agreed on 6 of 7 previously-pending chains exactly; `ton` disagreed only in address-flag encoding (bounceable vs. non-bounceable — same underlying key/hash, see that fixture entry's `_note`). This same run also caught a real bug: the Ethereum/BNB/Polygon address that had been marked `"verified"` since before this pass was actually wrong — the instrumented test that should have caught it had never successfully executed (two pre-existing bugs: `coin.name()` didn't compile against this Kotlin binding, and no `testInstrumentationRunner` was configured, so `connectedAndroidTest` silently ran "0 tests" instead of failing). Both are fixed now; see the test file's header comment for details.
|
|
66
99
|
|
|
67
|
-
**Byte-for-byte signing output is verified for 8 of 9 signable chains** in `conformance/signing-vectors.json`, asserted by `src/androidTest/.../SigningConformanceTest.kt`. Each vector calls `ChainSigner.sign()` — the same call path production code uses — with a fixed, deterministic (not necessarily broadcast-valid) unsigned tx. `ton` is verified but
|
|
100
|
+
**Byte-for-byte signing output is verified for 8 of 9 signable chains** in `conformance/signing-vectors.json`, asserted by `src/androidTest/.../SigningConformanceTest.kt`. Each vector calls `ChainSigner.sign()` — the same call path production code uses — with a fixed, deterministic (not necessarily broadcast-valid) unsigned tx. `ton` is verified but _not_ byte-exact-asserted: `signTon()` embeds a wall-clock `expireAt` into the signed payload, so its output legitimately differs every run — the test instead checks the output is a well-formed signed BOC. `bitcoincash` has no signing vector (sending is unsupported, see above).
|
|
101
|
+
|
|
102
|
+
Both Android suites are CI-gated on every push/PR (`.github/workflows/ci.yml`'s `android-unit-tests` and `android-instrumented-tests` jobs, via `example/` — see that folder's README for why an Expo host app is needed to build this module at all). iOS is CI-gated via the `ios-conformance-tests` job: `expo prebuild` generates the Xcode workspace, `pod install` wires up the `ConformanceTests` test_spec declared in the podspec (CocoaPods includes test_specs automatically — there is no `--include-test-specs` flag), and `xcodebuild test` runs both `AddressDerivationConformanceTests` and `SigningConformanceTests` on an iPhone 16 simulator.
|
|
103
|
+
|
|
104
|
+
**Tron divergence resolved.** iOS's `signTron` used to hand wallet-core the full `rawJson` and return its own reconstruction, diverging from Android's `txId`-only input + manual JSON reassembly — and `TronSigningInput` has no `rawJson` field in the pinned 4.1.19 anyway, so the old iOS code didn't compile. iOS now signs the same way Android does: `TronSigningInput.txID` set to the digest, signature reassembled into `{...tx, signature}` in app code (see `ios/ChainSigning.swift`'s `signTron`). Same key + same digest is guaranteed to produce the same ECDSA signature, so this is a structural fix, not something that needs a device to confirm.
|
|
68
105
|
|
|
69
|
-
**iOS
|
|
106
|
+
**iOS conformance suites are wired via CocoaPods `test_spec`.** `ios/ConformanceTests/AddressDerivationConformanceTests.swift` and `SigningConformanceTests.swift` mirror the Android fixtures above (all 10 address-derivation chains, the same 8 signing vectors, including a `testTron()` that asserts the reassembled signature matches Android's byte-for-byte). Both are declared as a `test_spec 'ConformanceTests'` in `ChainberryTrustWalletCoreModule.podspec`, which links them against `TrustWalletCore 4.1.19` and embeds the JSON fixtures as bundle resources. Running `pod install` from `example/ios` creates the `ChainberryTrustWalletCoreModule-ConformanceTests` scheme in the workspace automatically. The `ConformanceTests/` SwiftPM package continues to test only amount-parsing (no WalletCore dependency, runs standalone via `swift test`). The `tron` signing vector remains `"verified-android-only"` in the JSON until the iOS CI job's first green run confirms the signature matches byte-for-byte.
|
|
70
107
|
|
|
71
108
|
## License
|
|
72
109
|
|
|
@@ -44,7 +44,7 @@ class AddressDerivationConformanceTest {
|
|
|
44
44
|
|
|
45
45
|
// Chains with expected addresses confirmed against WalletCore 4.1.19, on a real device/emulator.
|
|
46
46
|
// ETH / BNB / POL all share CoinType.ETHEREUM (same secp256k1 key, BIP44 m/44'/60'/0'/0/0).
|
|
47
|
-
val
|
|
47
|
+
val verified = mapOf(
|
|
48
48
|
CoinType.ETHEREUM to "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", // ethereum / bnb / polygon
|
|
49
49
|
CoinType.SMARTCHAIN to "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", // bnb (same key as ethereum)
|
|
50
50
|
CoinType.BITCOIN to "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu",
|
|
@@ -63,13 +63,13 @@ class AddressDerivationConformanceTest {
|
|
|
63
63
|
|
|
64
64
|
// Steady state is empty — add a chain's CoinType here (and drop it from VERIFIED) only while
|
|
65
65
|
// actively harvesting a newly-added chain's address for the first time.
|
|
66
|
-
val
|
|
66
|
+
val pending = emptyList<CoinType>()
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
@Test
|
|
70
70
|
fun verifiedAddressesMatch() {
|
|
71
71
|
val wallet = HDWallet(MNEMONIC, "")
|
|
72
|
-
for ((coin, expected) in
|
|
72
|
+
for ((coin, expected) in verified) {
|
|
73
73
|
val actual = wallet.getAddressForCoin(coin)
|
|
74
74
|
assertEquals("address mismatch for coin=${coin.name}", expected, actual)
|
|
75
75
|
}
|
|
@@ -96,7 +96,7 @@ class AddressDerivationConformanceTest {
|
|
|
96
96
|
@Test
|
|
97
97
|
fun printPendingAddressesForVerification() {
|
|
98
98
|
val wallet = HDWallet(MNEMONIC, "")
|
|
99
|
-
val lines =
|
|
99
|
+
val lines = pending.map { coin -> " ${coin.name} -> ${wallet.getAddressForCoin(coin)}" }
|
|
100
100
|
println(
|
|
101
101
|
"\n[AddressDerivationConformanceTest] Pending — verify and move to VERIFIED map:\n" +
|
|
102
102
|
lines.joinToString("\n")
|
package/android/src/androidTest/java/com/chainberry/trustwalletcore/SigningConformanceTest.kt
CHANGED
|
@@ -125,7 +125,7 @@ class SigningConformanceTest {
|
|
|
125
125
|
// methodology). "ton" is deliberately absent — signTon() embeds a wall-clock
|
|
126
126
|
// `expireAt = now + 600s` into the signed payload, so its output is never byte-reproducible
|
|
127
127
|
// across runs; see tonVectorIsWellFormed() below for what's actually checked instead.
|
|
128
|
-
val
|
|
128
|
+
val expectedOutputs: Map<String, String> = mapOf(
|
|
129
129
|
"ethereum" to "0xf86c808504a817c800825208949858effd232b4033e47d90003d41ec34ecaeda94880de0b6b3a76400008026a0b7cca5f69561cd482cec4e692bd2da17d9eb1d6b3a83fe3f7f11d117de99ca97a00ad0dc153460e39b355c226a2744fc2926f84b95b89c954551571f1bbc251478",
|
|
130
130
|
"polygon" to "0x02f874818980843b9aca008509502f9000825208949858effd232b4033e47d90003d41ec34ecaeda94880de0b6b3a764000080c001a038b5c72ee38aa18607462b80ab0cb170a8d4df1c4090ac49acd41d6b4b0f9acca05bdee390ee8a50ff569f55d408371213df72a3065902b07317289b39788cb434",
|
|
131
131
|
"bitcoin" to "0100000000010100000000000000000000000000000000000000000000000000000000000000000000000000000000000250c3000000000000160014c0cebcd6c3d3ca8c75dc5ec62ebe55330ef910e2cebd000000000000160014c0cebcd6c3d3ca8c75dc5ec62ebe55330ef910e20247304402201e8f3663c97712bbf662c96df532e701ca9b660b507ab170d7b4ffbd966d4be0022033bd537c7720b1e7be2b7663d1a0adcbe565ff18a25794aec828015529ab80e101210330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c00000000",
|
|
@@ -160,7 +160,7 @@ class SigningConformanceTest {
|
|
|
160
160
|
fun signedOutputsMatch() {
|
|
161
161
|
val wallet = HDWallet(MNEMONIC, "")
|
|
162
162
|
for ((chain, pair) in vectors()) {
|
|
163
|
-
val expected =
|
|
163
|
+
val expected = expectedOutputs[chain] ?: continue
|
|
164
164
|
val (chainKey, unsignedTx) = pair
|
|
165
165
|
val actual = ChainSigner.sign(chainKey, wallet, unsignedTx, isTestnet = false).signedTx
|
|
166
166
|
assertEquals("signed output mismatch for chain=$chain", expected, actual)
|
|
@@ -13,11 +13,14 @@ import wallet.core.jni.Hash
|
|
|
13
13
|
import wallet.core.jni.PrivateKey
|
|
14
14
|
import wallet.core.jni.SolanaTransaction
|
|
15
15
|
import wallet.core.jni.TransactionDecoder
|
|
16
|
+
import wallet.core.jni.proto.Aptos
|
|
16
17
|
import wallet.core.jni.proto.Bitcoin
|
|
17
18
|
import wallet.core.jni.proto.Common
|
|
19
|
+
import wallet.core.jni.proto.Cosmos
|
|
18
20
|
import wallet.core.jni.proto.Ethereum
|
|
19
21
|
import wallet.core.jni.proto.Ripple
|
|
20
22
|
import wallet.core.jni.proto.Solana
|
|
23
|
+
import wallet.core.jni.proto.Tezos
|
|
21
24
|
import wallet.core.jni.proto.TheOpenNetwork
|
|
22
25
|
import wallet.core.jni.proto.Tron
|
|
23
26
|
import java.math.BigInteger
|
|
@@ -51,13 +54,19 @@ enum class ChainKey(val coinType: CoinType) {
|
|
|
51
54
|
BITCOINCASH(CoinType.BITCOINCASH),
|
|
52
55
|
DOGECOIN(CoinType.DOGECOIN),
|
|
53
56
|
LITECOIN(CoinType.LITECOIN),
|
|
54
|
-
XRP(CoinType.XRP)
|
|
57
|
+
XRP(CoinType.XRP),
|
|
58
|
+
COSMOS(CoinType.COSMOS),
|
|
59
|
+
APTOS(CoinType.APTOS),
|
|
60
|
+
TEZOS(CoinType.TEZOS);
|
|
55
61
|
|
|
56
62
|
val symbol: String get() = when (this) {
|
|
57
63
|
ETHEREUM -> "ETH"; BNB -> "BNB"; POLYGON -> "POL"
|
|
58
64
|
AVAX -> "AVAX"; BASE -> "ETH"; ARBITRUM -> "ETH"; OPTIMISM -> "ETH"; SONIC -> "S"
|
|
59
65
|
SOLANA -> "SOL"; TRON -> "TRX"; TON -> "TON"
|
|
60
66
|
BITCOIN -> "BTC"; BITCOINCASH -> "BCH"; DOGECOIN -> "DOGE"; LITECOIN -> "LTC"; XRP -> "XRP"
|
|
67
|
+
COSMOS -> "ATOM"
|
|
68
|
+
APTOS -> "APT"
|
|
69
|
+
TEZOS -> "XTZ"
|
|
61
70
|
}
|
|
62
71
|
|
|
63
72
|
companion object {
|
|
@@ -144,6 +153,9 @@ object ChainSigner {
|
|
|
144
153
|
ChainKey.XRP -> ChainSignResult(signXrp(wallet, unsignedTx), null)
|
|
145
154
|
ChainKey.TON -> signTon(wallet, unsignedTx)
|
|
146
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)
|
|
147
159
|
}
|
|
148
160
|
|
|
149
161
|
// MARK: - EVM (ethereum / bnb / polygon)
|
|
@@ -432,6 +444,170 @@ object ChainSigner {
|
|
|
432
444
|
if (output.error != Common.SigningError.OK) throw ChainSigningException("Signing failed: ${output.errorMessage}")
|
|
433
445
|
return ChainSignResult(output.encoded, mapOf("txHash" to output.hash.toByteArray().toHex()))
|
|
434
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
|
+
}
|
|
435
611
|
}
|
|
436
612
|
|
|
437
613
|
// Transaction summary helpers (used by ChainberryTrustWalletCoreModule for native confirmation UI)
|
|
@@ -450,12 +626,42 @@ internal fun ChainSigner.buildSummary(chain: ChainKey, unsignedTx: Map<String, A
|
|
|
450
626
|
)
|
|
451
627
|
val feeWei = gasLimit * gasPrice
|
|
452
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
|
+
}
|
|
453
651
|
}
|
|
454
652
|
ChainKey.BITCOIN, ChainKey.DOGECOIN, ChainKey.LITECOIN -> {
|
|
455
653
|
(unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
|
|
456
|
-
(unsignedTx["sendAmountSats"] as? String)?.toLongOrNull()
|
|
457
|
-
|
|
458
|
-
}
|
|
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}"
|
|
459
665
|
}
|
|
460
666
|
ChainKey.XRP -> {
|
|
461
667
|
(unsignedTx["Destination"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
|
|
@@ -472,34 +678,203 @@ internal fun ChainSigner.buildSummary(chain: ChainKey, unsignedTx: Map<String, A
|
|
|
472
678
|
(unsignedTx["amount"] as? String)?.toULongOrNull()?.let {
|
|
473
679
|
lines += "Amount: ${fmtAmt(it.toDouble() / 1e9)} TON"
|
|
474
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)"
|
|
475
684
|
}
|
|
476
685
|
ChainKey.TRON -> {
|
|
477
686
|
@Suppress("UNCHECKED_CAST")
|
|
478
|
-
val
|
|
687
|
+
val firstContract = (unsignedTx["raw_data"] as? Map<String, Any>)
|
|
479
688
|
?.let { (it["contract"] as? List<Map<String, Any>>)?.firstOrNull() }
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
(
|
|
689
|
+
firstContract?.let { contract ->
|
|
690
|
+
val contractType = contract["type"] as? String ?: ""
|
|
691
|
+
val value = (contract["parameter"] as? Map<String, Any>)
|
|
692
|
+
?.let { it["value"] as? Map<String, Any> }
|
|
693
|
+
when (contractType) {
|
|
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 (contractType.isNotEmpty()) lines += "Contract type: $contractType — review carefully"
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
// Verify txID == SHA256(raw_data_hex). Both fields must be present — fail closed if either
|
|
717
|
+
// is absent so a JS caller cannot suppress the integrity check by omitting raw_data_hex.
|
|
718
|
+
val txId = unsignedTx["txID"] as? String
|
|
719
|
+
?: throw ChainSigningException("TRX txID missing — signing refused")
|
|
720
|
+
val rawHex = unsignedTx["raw_data_hex"] as? String
|
|
721
|
+
?: throw ChainSigningException("TRX raw_data_hex missing — cannot verify txID, signing refused")
|
|
722
|
+
val computed = java.security.MessageDigest.getInstance("SHA-256")
|
|
723
|
+
.digest(rawHex.hexToBytes()).toHex()
|
|
724
|
+
if (computed.lowercase() != txId.lowercase())
|
|
725
|
+
throw ChainSigningException("TRX txID does not match SHA256(raw_data_hex) — signing refused")
|
|
726
|
+
lines += "TxID verified ✓"
|
|
727
|
+
}
|
|
728
|
+
ChainKey.SOLANA -> {
|
|
729
|
+
val info = (unsignedTx["unsignedTxBase64"] as? String)?.let { decodeSolanaForSummary(it) }
|
|
730
|
+
?: throw ChainSigningException(
|
|
731
|
+
"Cannot decode Solana transaction — signing refused to prevent blind signing"
|
|
732
|
+
)
|
|
733
|
+
if (info.isSplTransfer) {
|
|
734
|
+
info.splDest?.let { lines += "SPL Token to: ${fmtAddr(it)}" }
|
|
735
|
+
info.splAmount?.let { lines += "SPL Token amount (raw): $it" }
|
|
736
|
+
} else {
|
|
737
|
+
info.to?.let { lines += "To: ${fmtAddr(it)}" }
|
|
738
|
+
info.lamports?.let { lines += "Amount: ${fmtAmt(it.toDouble() / 1e9)} SOL" }
|
|
739
|
+
if (!info.isTransfer) lines += "Non-transfer instruction — review carefully"
|
|
485
740
|
}
|
|
486
741
|
}
|
|
487
|
-
ChainKey.SOLANA -> lines += "(Solana — details verified by the network)"
|
|
488
742
|
ChainKey.BITCOINCASH -> {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
} catch (_: Exception) {
|
|
743
|
+
// Fail closed — if descriptor is absent or unparseable we cannot show what will be signed.
|
|
744
|
+
val descriptorJson = unsignedTx["unsignedDescriptorJson"] as? String
|
|
745
|
+
?: throw ChainSigningException("Cannot decode BCH descriptor — signing refused to prevent blind signing")
|
|
746
|
+
val descriptor = try {
|
|
747
|
+
JSONObject(descriptorJson)
|
|
748
|
+
} catch (_: Exception) {
|
|
749
|
+
throw ChainSigningException("Cannot parse BCH descriptor JSON — signing refused")
|
|
750
|
+
}
|
|
751
|
+
descriptor.optString("toAddress").takeIf { it.isNotEmpty() }?.let { lines += "To: ${fmtAddr(it)}" }
|
|
752
|
+
val sendSats = descriptor.optLong("sendAmountSats", -1L)
|
|
753
|
+
if (sendSats >= 0) lines += "Amount: ${fmtAmt(sendSats.toDouble() / 1e8)} BCH"
|
|
754
|
+
descriptor.optString("changeAddress").takeIf { it.isNotEmpty() }?.let { lines += "Change to: ${fmtAddr(it)}" }
|
|
755
|
+
val spb = descriptor.optInt("satsPerByte", -1)
|
|
756
|
+
if (spb >= 0) lines += "Fee rate: $spb sat/vB"
|
|
757
|
+
@Suppress("UNCHECKED_CAST")
|
|
758
|
+
val inputTotal = (unsignedTx["inputs"] as? List<Map<String, Any>>)
|
|
759
|
+
?.mapNotNull { (it["amountSats"] as? String)?.toLongOrNull() }
|
|
760
|
+
?.fold(0L, Long::plus) ?: descriptor.optLong("inputTotalSats", 0L)
|
|
761
|
+
val changeSats = descriptor.optLong("changeAmountSats", 0L)
|
|
762
|
+
val totalFee = inputTotal - sendSats.coerceAtLeast(0L) - changeSats
|
|
763
|
+
if (totalFee > 0) lines += "Total fee: ${fmtAmt(totalFee.toDouble() / 1e8)} BCH"
|
|
764
|
+
}
|
|
765
|
+
ChainKey.COSMOS -> {
|
|
766
|
+
(unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
|
|
767
|
+
(unsignedTx["amount"] as? String)?.toLongOrNull()?.let {
|
|
768
|
+
lines += "Amount: ${fmtAmt(it.toDouble() / 1_000_000.0)} ATOM"
|
|
769
|
+
}
|
|
770
|
+
(unsignedTx["feeAmount"] as? String)?.toLongOrNull()?.let {
|
|
771
|
+
lines += "Fee: ${fmtAmt(it.toDouble() / 1_000_000.0)} ATOM"
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
ChainKey.APTOS -> {
|
|
775
|
+
(unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
|
|
776
|
+
(unsignedTx["amount"] as? String)?.toLongOrNull()?.let {
|
|
777
|
+
lines += "Amount: ${fmtAmt(it.toDouble() / 1e8)} APT"
|
|
778
|
+
}
|
|
779
|
+
val maxGas = (unsignedTx["maxGasAmount"] as? Number)?.toLong()
|
|
780
|
+
val gasPrice = (unsignedTx["gasUnitPrice"] as? Number)?.toLong()
|
|
781
|
+
if (maxGas != null && gasPrice != null) {
|
|
782
|
+
lines += "Max fee: ${fmtAmt((maxGas * gasPrice).toDouble() / 1e8)} APT"
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
ChainKey.TEZOS -> {
|
|
786
|
+
(unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
|
|
787
|
+
(unsignedTx["amount"] as? Number)?.toLong()?.let {
|
|
788
|
+
lines += "Amount: ${fmtAmt(it.toDouble() / 1_000_000.0)} XTZ"
|
|
789
|
+
}
|
|
790
|
+
(unsignedTx["fee"] as? Number)?.toLong()?.let {
|
|
791
|
+
lines += "Fee: ${fmtAmt(it.toDouble() / 1_000_000.0)} XTZ"
|
|
792
|
+
}
|
|
793
|
+
if (unsignedTx["needsReveal"] == true) lines += "(includes reveal operation)"
|
|
495
794
|
}
|
|
496
795
|
}
|
|
497
796
|
return lines.joinToString("\n")
|
|
498
797
|
}
|
|
499
798
|
|
|
799
|
+
private data class SolanaSummary(
|
|
800
|
+
val to: String?,
|
|
801
|
+
val lamports: ULong?,
|
|
802
|
+
val isTransfer: Boolean,
|
|
803
|
+
val splDest: String?,
|
|
804
|
+
val splAmount: ULong?,
|
|
805
|
+
val isSplTransfer: Boolean
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
private fun decodeSolanaForSummary(b64: String): SolanaSummary? {
|
|
809
|
+
return try {
|
|
810
|
+
val txBytes = android.util.Base64.decode(b64, android.util.Base64.DEFAULT)
|
|
811
|
+
val decoded = Solana.DecodingTransactionOutput.parseFrom(TransactionDecoder.decode(CoinType.SOLANA, txBytes))
|
|
812
|
+
if (decoded.error != Common.SigningError.OK) return null
|
|
813
|
+
val accounts = decoded.transaction.legacy.accountKeysList
|
|
814
|
+
val systemProgram = "11111111111111111111111111111111"
|
|
815
|
+
val splPrograms = setOf(
|
|
816
|
+
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
|
|
817
|
+
"TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
var systemIx: Solana.RawMessage.Instruction? = null
|
|
821
|
+
var splIx: Solana.RawMessage.Instruction? = null
|
|
822
|
+
for (instr in decoded.transaction.legacy.instructionsList) {
|
|
823
|
+
val prog = accounts.getOrNull(instr.programId)
|
|
824
|
+
if (systemIx == null && prog == systemProgram) systemIx = instr
|
|
825
|
+
if (splIx == null && prog != null && prog in splPrograms) splIx = instr
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (systemIx != null) {
|
|
829
|
+
val ix = systemIx
|
|
830
|
+
val to = if (ix.accountsCount >= 2) accounts.getOrNull(ix.accountsList[1]) else null
|
|
831
|
+
val dataBytes = ix.programData.toByteArray()
|
|
832
|
+
// SystemProgram Transfer discriminator: [2, 0, 0, 0] as u32-LE
|
|
833
|
+
val isTransfer = dataBytes.size >= 12 &&
|
|
834
|
+
dataBytes[0] == 2.toByte() && dataBytes[1] == 0.toByte() &&
|
|
835
|
+
dataBytes[2] == 0.toByte() && dataBytes[3] == 0.toByte()
|
|
836
|
+
val lamports = if (isTransfer) {
|
|
837
|
+
var v = 0UL
|
|
838
|
+
for (i in 0..7) v = v or (dataBytes[4 + i].toUByte().toULong() shl (i * 8))
|
|
839
|
+
v
|
|
840
|
+
} else null
|
|
841
|
+
return SolanaSummary(to, lamports, isTransfer, null, null, false)
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (splIx != null) {
|
|
845
|
+
val ix = splIx
|
|
846
|
+
val dataBytes = ix.programData.toByteArray()
|
|
847
|
+
// SPL instruction byte 0: 3 = Transfer, 12 = TransferChecked
|
|
848
|
+
// Transfer: accounts[0]=src, [1]=dest, [2]=owner; data[1..8]=amount LE u64
|
|
849
|
+
// TransferChecked: accounts[0]=src, [1]=mint, [2]=dest, [3]=owner
|
|
850
|
+
return when {
|
|
851
|
+
dataBytes.isNotEmpty() && dataBytes[0] == 3.toByte() && dataBytes.size >= 9 -> {
|
|
852
|
+
val dest = if (ix.accountsCount >= 2) accounts.getOrNull(ix.accountsList[1]) else null
|
|
853
|
+
var amount = 0UL
|
|
854
|
+
for (i in 0..7) amount = amount or (dataBytes[1 + i].toUByte().toULong() shl (i * 8))
|
|
855
|
+
SolanaSummary(null, null, false, dest, amount, true)
|
|
856
|
+
}
|
|
857
|
+
dataBytes.isNotEmpty() && dataBytes[0] == 12.toByte() && dataBytes.size >= 10 -> {
|
|
858
|
+
val dest = if (ix.accountsCount >= 3) accounts.getOrNull(ix.accountsList[2]) else null
|
|
859
|
+
var amount = 0UL
|
|
860
|
+
for (i in 0..7) amount = amount or (dataBytes[1 + i].toUByte().toULong() shl (i * 8))
|
|
861
|
+
SolanaSummary(null, null, false, dest, amount, true)
|
|
862
|
+
}
|
|
863
|
+
else -> SolanaSummary(null, null, false, null, null, false)
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
null
|
|
868
|
+
} catch (_: Exception) {
|
|
869
|
+
null
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
500
873
|
private fun txHexToDouble(hex: String): Double = try {
|
|
501
874
|
BigInteger(hex.removePrefix("0x").ifEmpty { "0" }, 16).toDouble()
|
|
502
|
-
} catch (_: NumberFormatException) {
|
|
875
|
+
} catch (_: NumberFormatException) {
|
|
876
|
+
0.0
|
|
877
|
+
}
|
|
503
878
|
|
|
504
879
|
private fun fmtAmt(value: Double): String =
|
|
505
880
|
"%.8f".format(value).trimEnd('0').trimEnd('.')
|