@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.
@@ -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, '16.0'
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 = 'ios/**/*.{h,m,mm,swift}'
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 config plugin module with native Android/iOS code, so it requires a development build (`expo prebuild` / EAS Build) — it will not work in Expo Go.
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 { createWallet, importWallet, signTransaction, exportMnemonic } from "@chainberry/trust-wallet-core";
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; not biometric-gated (deleting reveals nothing, so this is a UX confirmation concern, not a key-secrecy one).
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 *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).
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 gap not closed by this pass.** No Swift/Xcode toolchain was available in the environment that did this verification, so none of the above has been independently confirmed on iOS. `ChainKey.coinType` maps identically to Android's, so derivation *should* match, but this hasn't been checked on-device. `ConformanceTests/` currently has no address-derivation or signing test target at all (only the pre-existing amount-parsing one) before this finding is fully closed for both platforms, an iOS engineer needs to (1) add `AddressDerivationConformanceTests`/`SigningConformanceTests` targets mirroring the Android ones, (2) run them against these same fixtures on a Mac, and (3) specifically resolve the **Tron divergence**: Android's `signTron` signs just the `txId` digest and reassembles JSON in app code, while iOS's hands wallet-core the full `rawJson` and returns its own reconstruction — `conformance/signing-vectors.json`'s `tron` entry spells out exactly what to check (the embedded signature hex must match Android's byte-for-byte; a mismatch there is a real bug, not a formatting difference).
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 VERIFIED = mapOf(
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 PENDING = emptyList<CoinType>()
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 VERIFIED) {
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 = PENDING.map { coin -> " ${coin.name} -> ${wallet.getAddressForCoin(coin)}" }
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")
@@ -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 EXPECTED: Map<String, String> = mapOf(
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 = EXPECTED[chain] ?: continue
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()?.let {
457
- lines += "Amount: ${fmtAmt(it.toDouble() / 1e8)} ${chain.symbol}"
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 value = ((unsignedTx["raw_data"] as? Map<String, Any>)
687
+ val firstContract = (unsignedTx["raw_data"] as? Map<String, Any>)
479
688
  ?.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" }
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
- 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) {}
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) { 0.0 }
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('.')