@chainberry/trust-wallet-core 2.5.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,11 +10,12 @@ 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'
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
 
@@ -27,7 +27,12 @@ Android biometric gating additionally pulls in `androidx.biometric:biometric:1.1
27
27
  ## Usage
28
28
 
29
29
  ```ts
30
- import { createWallet, importWallet, signTransaction, exportMnemonic } from "@chainberry/trust-wallet-core";
30
+ import {
31
+ createWallet,
32
+ importWallet,
33
+ signTransaction,
34
+ exportMnemonic,
35
+ } from "@chainberry/trust-wallet-core";
31
36
 
32
37
  const { walletId, addresses } = await createWallet(); // 128-bit / 12-word by default
33
38
  // addresses: { ethereum: "0x...", solana: "...", bnb: "0x...", bitcoin: "...", ... }
@@ -46,7 +51,7 @@ const mnemonic = await exportMnemonic(walletId);
46
51
  - `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
52
  - `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
53
  - `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).
54
+ - `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
55
  - `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
56
  - `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
57
 
@@ -64,9 +69,13 @@ Both platforms pin **Trust Wallet Core 4.1.19** (`com.trustwallet:wallet-core:4.
64
69
 
65
70
  **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
71
 
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).
72
+ **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).
68
73
 
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).
74
+ 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's CI job is still a structural podspec lint only; see the conformance-suite note below for what's missing to close that gap.
75
+
76
+ **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.
77
+
78
+ **iOS conformance suites are written but not yet wired into a runnable target.** `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). Neither file is part of the `ConformanceTests/` SwiftPM package (which still only builds the amount-parsing target — these two `import WalletCore`/`import XCTest`, which that dependency-free package deliberately avoids) or of any Xcode project (`ios/` is gitignored, regenerated by `expo prebuild`, so nothing durable lives there). An iOS engineer still needs to add both files to an XCTest target in the host app's workspace that links `WalletCore.xcframework`, run them on a Mac, and promote `conformance/signing-vectors.json`'s `tron` vector off `"verified-android-only"` once that run confirms the signature match. Until then, `ChainKey.coinType` mapping identically to Android's makes address derivation _likely_ correct on iOS, but this hasn't been independently run on-device.
70
79
 
71
80
  ## License
72
81
 
@@ -15,6 +15,9 @@ import wallet.core.jni.SolanaTransaction
15
15
  import wallet.core.jni.TransactionDecoder
16
16
  import wallet.core.jni.proto.Bitcoin
17
17
  import wallet.core.jni.proto.Common
18
+ import wallet.core.jni.proto.Aptos
19
+ import wallet.core.jni.proto.Cosmos
20
+ import wallet.core.jni.proto.Tezos
18
21
  import wallet.core.jni.proto.Ethereum
19
22
  import wallet.core.jni.proto.Ripple
20
23
  import wallet.core.jni.proto.Solana
@@ -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,16 +678,66 @@ 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 type_ = contract["type"] as? String ?: ""
691
+ val value = (contract["parameter"] as? Map<String, Any>)
692
+ ?.let { it["value"] as? Map<String, Any> }
693
+ when (type_) {
694
+ "TransferContract" -> value?.let { v ->
695
+ (v["to_address"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
696
+ (v["amount"] as? Number)?.let { lines += "Amount: ${fmtAmt(it.toDouble() / 1e6)} TRX" }
697
+ }
698
+ "TriggerSmartContract" -> value?.let { v ->
699
+ (v["contract_address"] as? String)?.let { lines += "Token contract: ${fmtAddr(it)}" }
700
+ val dataHex = (v["data"] as? String) ?: ""
701
+ val stripped = dataHex.removePrefix("0x")
702
+ val sel = stripped.take(8).lowercase()
703
+ if (sel == "a9059cbb" && stripped.length >= 136) {
704
+ // TRC-20 transfer(address, uint256) — ABI encoding identical to EVM
705
+ val recipientHex = "0x" + stripped.drop(32).take(40)
706
+ val amountHex = stripped.drop(72).take(64).trimStart('0').ifEmpty { "0" }
707
+ lines += "TRC-20 to: ${fmtAddr(recipientHex)}"
708
+ lines += "Token amount (raw units): 0x$amountHex"
709
+ } else {
710
+ lines += "Contract call: ${stripped.length / 2} bytes — review carefully"
711
+ }
712
+ }
713
+ else -> if (type_.isNotEmpty()) lines += "Contract type: $type_ — review carefully"
714
+ }
715
+ }
716
+ (unsignedTx["txID"] as? String)?.let { txId ->
717
+ val rawHex = unsignedTx["raw_data_hex"] as? String
718
+ if (rawHex != null) {
719
+ val computed = java.security.MessageDigest.getInstance("SHA-256")
720
+ .digest(rawHex.hexToBytes()).toHex()
721
+ if (computed.lowercase() != txId.lowercase())
722
+ throw ChainSigningException("TRX txID does not match SHA256(raw_data_hex) — signing refused")
723
+ lines += "TxID verified ✓"
724
+ } else {
725
+ lines += "TxID: ${txId.take(16)}… (raw_data_hex absent — unverified)"
726
+ }
727
+ }
728
+ }
729
+ ChainKey.SOLANA -> {
730
+ val info = (unsignedTx["unsignedTxBase64"] as? String)?.let { decodeSolanaForSummary(it) }
731
+ ?: throw ChainSigningException(
732
+ "Cannot decode Solana transaction — signing refused to prevent blind signing"
733
+ )
734
+ if (info.isSplTransfer) {
735
+ info.splDest?.let { lines += "SPL Token to: ${fmtAddr(it)}" }
736
+ info.splAmount?.let { lines += "SPL Token amount (raw): $it" }
737
+ } else {
738
+ info.to?.let { lines += "To: ${fmtAddr(it)}" }
739
+ info.lamports?.let { lines += "Amount: ${fmtAmt(it.toDouble() / 1e9)} SOL" }
740
+ if (!info.isTransfer) lines += "Non-transfer instruction — review carefully"
485
741
  }
486
742
  }
487
743
  ChainKey.SOLANA -> lines += "(Solana — details verified by the network)"
@@ -493,10 +749,112 @@ internal fun ChainSigner.buildSummary(chain: ChainKey, unsignedTx: Map<String, A
493
749
  if (sats >= 0) lines += "Amount: ${fmtAmt(sats.toDouble() / 1e8)} BCH"
494
750
  } catch (_: Exception) {}
495
751
  }
752
+ ChainKey.COSMOS -> {
753
+ (unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
754
+ (unsignedTx["amount"] as? String)?.toLongOrNull()?.let {
755
+ lines += "Amount: ${fmtAmt(it.toDouble() / 1_000_000.0)} ATOM"
756
+ }
757
+ (unsignedTx["feeAmount"] as? String)?.toLongOrNull()?.let {
758
+ lines += "Fee: ${fmtAmt(it.toDouble() / 1_000_000.0)} ATOM"
759
+ }
760
+ }
761
+ ChainKey.APTOS -> {
762
+ (unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
763
+ (unsignedTx["amount"] as? String)?.toLongOrNull()?.let {
764
+ lines += "Amount: ${fmtAmt(it.toDouble() / 1e8)} APT"
765
+ }
766
+ val maxGas = (unsignedTx["maxGasAmount"] as? Number)?.toLong()
767
+ val gasPrice = (unsignedTx["gasUnitPrice"] as? Number)?.toLong()
768
+ if (maxGas != null && gasPrice != null) {
769
+ lines += "Max fee: ${fmtAmt((maxGas * gasPrice).toDouble() / 1e8)} APT"
770
+ }
771
+ }
772
+ ChainKey.TEZOS -> {
773
+ (unsignedTx["toAddress"] as? String)?.let { lines += "To: ${fmtAddr(it)}" }
774
+ (unsignedTx["amount"] as? Number)?.toLong()?.let {
775
+ lines += "Amount: ${fmtAmt(it.toDouble() / 1_000_000.0)} XTZ"
776
+ }
777
+ (unsignedTx["fee"] as? Number)?.toLong()?.let {
778
+ lines += "Fee: ${fmtAmt(it.toDouble() / 1_000_000.0)} XTZ"
779
+ }
780
+ if (unsignedTx["needsReveal"] == true) lines += "(includes reveal operation)"
781
+ }
496
782
  }
497
783
  return lines.joinToString("\n")
498
784
  }
499
785
 
786
+ private data class SolanaSummary(
787
+ val to: String?,
788
+ val lamports: ULong?,
789
+ val isTransfer: Boolean,
790
+ val splDest: String?,
791
+ val splAmount: ULong?,
792
+ val isSplTransfer: Boolean
793
+ )
794
+
795
+ private fun decodeSolanaForSummary(b64: String): SolanaSummary? {
796
+ return try {
797
+ val txBytes = android.util.Base64.decode(b64, android.util.Base64.DEFAULT)
798
+ val decoded = Solana.DecodingTransactionOutput.parseFrom(TransactionDecoder.decode(CoinType.SOLANA, txBytes))
799
+ if (decoded.error != Common.SigningError.OK) return null
800
+ val accounts = decoded.transaction.legacy.accountKeysList
801
+ val systemProgram = "11111111111111111111111111111111"
802
+ val splPrograms = setOf(
803
+ "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
804
+ "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
805
+ )
806
+
807
+ var systemIx: Solana.RawMessage.Instruction? = null
808
+ var splIx: Solana.RawMessage.Instruction? = null
809
+ for (instr in decoded.transaction.legacy.instructionsList) {
810
+ val prog = accounts.getOrNull(instr.programId)
811
+ if (systemIx == null && prog == systemProgram) systemIx = instr
812
+ if (splIx == null && prog != null && prog in splPrograms) splIx = instr
813
+ }
814
+
815
+ if (systemIx != null) {
816
+ val ix = systemIx
817
+ val to = if (ix.accountsCount >= 2) accounts.getOrNull(ix.accountsList[1]) else null
818
+ val dataBytes = ix.programData.toByteArray()
819
+ // SystemProgram Transfer discriminator: [2, 0, 0, 0] as u32-LE
820
+ val isTransfer = dataBytes.size >= 12 &&
821
+ dataBytes[0] == 2.toByte() && dataBytes[1] == 0.toByte() &&
822
+ dataBytes[2] == 0.toByte() && dataBytes[3] == 0.toByte()
823
+ val lamports = if (isTransfer) {
824
+ var v = 0UL
825
+ for (i in 0..7) v = v or (dataBytes[4 + i].toUByte().toULong() shl (i * 8))
826
+ v
827
+ } else null
828
+ return SolanaSummary(to, lamports, isTransfer, null, null, false)
829
+ }
830
+
831
+ if (splIx != null) {
832
+ val ix = splIx
833
+ val dataBytes = ix.programData.toByteArray()
834
+ // SPL instruction byte 0: 3 = Transfer, 12 = TransferChecked
835
+ // Transfer: accounts[0]=src, [1]=dest, [2]=owner; data[1..8]=amount LE u64
836
+ // TransferChecked: accounts[0]=src, [1]=mint, [2]=dest, [3]=owner
837
+ return when {
838
+ dataBytes.isNotEmpty() && dataBytes[0] == 3.toByte() && dataBytes.size >= 9 -> {
839
+ val dest = if (ix.accountsCount >= 2) accounts.getOrNull(ix.accountsList[1]) else null
840
+ var amount = 0UL
841
+ for (i in 0..7) amount = amount or (dataBytes[1 + i].toUByte().toULong() shl (i * 8))
842
+ SolanaSummary(null, null, false, dest, amount, true)
843
+ }
844
+ dataBytes.isNotEmpty() && dataBytes[0] == 12.toByte() && dataBytes.size >= 10 -> {
845
+ val dest = if (ix.accountsCount >= 3) accounts.getOrNull(ix.accountsList[2]) else null
846
+ var amount = 0UL
847
+ for (i in 0..7) amount = amount or (dataBytes[1 + i].toUByte().toULong() shl (i * 8))
848
+ SolanaSummary(null, null, false, dest, amount, true)
849
+ }
850
+ else -> SolanaSummary(null, null, false, null, null, false)
851
+ }
852
+ }
853
+
854
+ null
855
+ } catch (_: Exception) { null }
856
+ }
857
+
500
858
  private fun txHexToDouble(hex: String): Double = try {
501
859
  BigInteger(hex.removePrefix("0x").ifEmpty { "0" }, 16).toDouble()
502
860
  } catch (_: NumberFormatException) { 0.0 }
@@ -1,11 +1,13 @@
1
1
  package com.chainberry.trustwalletcore
2
2
 
3
3
  import android.app.AlertDialog
4
+ import android.util.Log
4
5
  import androidx.fragment.app.FragmentActivity
5
6
  import expo.modules.kotlin.exception.CodedException
6
7
  import expo.modules.kotlin.functions.Coroutine
7
8
  import expo.modules.kotlin.modules.Module
8
9
  import expo.modules.kotlin.modules.ModuleDefinition
10
+ import kotlinx.coroutines.sync.Mutex
9
11
  import kotlinx.coroutines.suspendCancellableCoroutine
10
12
  import wallet.core.jni.HDWallet
11
13
  import java.util.UUID
@@ -21,6 +23,13 @@ class ChainberryTrustWalletCoreModule : Module() {
21
23
  // Must be loaded once before any JNI calls
22
24
  System.loadLibrary("TrustWalletCore")
23
25
  }
26
+
27
+ // Global, not per-walletId: createWallet/importWallet/deleteWallet all read-modify-write the
28
+ // *entire* metadata map, so two concurrent calls touching different wallet ids would still
29
+ // race each other on that shared blob — a per-id lock wouldn't protect against that. See
30
+ // docs/adr/0002. A single companion-object Mutex (rather than an instance property) keeps
31
+ // this a true process-wide lock even if more than one module instance is ever created.
32
+ private val lifecycleMutex = Mutex()
24
33
  }
25
34
 
26
35
  private val context get() = appContext.reactContext
@@ -30,9 +39,53 @@ class ChainberryTrustWalletCoreModule : Module() {
30
39
  get() = appContext.currentActivity as? FragmentActivity
31
40
  ?: throw CodedException("NoActivity", "No foreground FragmentActivity to host the biometric prompt", null)
32
41
 
42
+ /**
43
+ * Serializes the whole body — including the biometric/device-credential prompt, not just the
44
+ * store writes — against every other lifecycle-mutating call, so at most one is ever touching
45
+ * the shared metadata store at a time (see docs/adr/0002). Also forecloses a second, separate
46
+ * bug: two concurrent `BiometricPrompt` invocations racing each other.
47
+ *
48
+ * [rejectIfBusy] chooses the policy for a caller that finds the lock already held:
49
+ * `createWallet`/`importWallet` reject immediately (`ERR_WALLET_OPERATION_IN_PROGRESS`) so a
50
+ * double-tap can never mint two wallets; `deleteWallet` queues instead, since two distinct
51
+ * deletes are both legitimate and should both eventually happen.
52
+ */
53
+ private suspend fun <T> withLifecycleLock(rejectIfBusy: Boolean, body: suspend () -> T): T {
54
+ if (rejectIfBusy) {
55
+ if (!lifecycleMutex.tryLock()) {
56
+ throw CodedException(
57
+ "ERR_WALLET_OPERATION_IN_PROGRESS",
58
+ "Another wallet operation is already in progress",
59
+ null,
60
+ )
61
+ }
62
+ } else {
63
+ lifecycleMutex.lock()
64
+ }
65
+ try {
66
+ return body()
67
+ } finally {
68
+ lifecycleMutex.unlock()
69
+ }
70
+ }
71
+
33
72
  override fun definition() = ModuleDefinition {
34
73
  Name("TrustWalletCore")
35
74
 
75
+ // Runs once, right after module init, before any of the AsyncFunctions below can be
76
+ // reached from JS — so nothing can legitimately be mid-operation yet, which is exactly what
77
+ // makes a one-shot pass here sufficient (no lock/grace-period needed against an in-flight
78
+ // call). This is the actual crash-safety mechanism for an interrupted create/delete; see
79
+ // CONTEXT.md and docs/adr/0001. Never allowed to block or crash startup.
80
+ OnCreate {
81
+ val reactContext = appContext.reactContext
82
+ if (reactContext == null) {
83
+ Log.w("TrustWalletCoreModule", "reconciliation: react context unavailable at module init, skipping")
84
+ } else {
85
+ NativeWalletStore.reconcileOrphans(reactContext)
86
+ }
87
+ }
88
+
36
89
  // strength 128 = 12 words, 256 = 24 words. Returns { walletId, addresses }.
37
90
  // No BIP-39 passphrase support: signTransaction always reconstructs the wallet with an
38
91
  // empty passphrase, so accepting one here would derive addresses from a seed different
@@ -62,13 +115,21 @@ class ChainberryTrustWalletCoreModule : Module() {
62
115
  // anything is deleted, same gate as `signTransaction`/`exportMnemonic`. A
63
116
  // compromised/malicious JS caller can still invoke this directly (there's no UI call
64
117
  // site today), so the gate must live here rather than in JS.
118
+ //
119
+ // Removes the metadata entry *before* the secret (mnemonic file + Keystore key) — the
120
+ // reverse of the old ordering. If this is interrupted between the two steps, the wallet is
121
+ // already gone from `listWallets` and only an orphaned secret-side resource is left behind,
122
+ // which the next app launch's reconciliation pass cleans up (see docs/adr/0001) — never a
123
+ // metadata record still pointing at a secret that's already gone.
65
124
  AsyncFunction("deleteWallet") Coroutine { walletId: String ->
66
- val id = NativeWalletStore.validateWalletId(walletId)
67
- NativeWalletStore.confirmIdentity(activity, context, "Delete wallet")
68
- NativeWalletStore.deleteMnemonic(context, id)
69
- val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
70
- metadata.remove(id)
71
- NativeWalletStore.saveMetadata(context, metadata)
125
+ withLifecycleLock(rejectIfBusy = false) {
126
+ val id = NativeWalletStore.validateWalletId(walletId)
127
+ NativeWalletStore.confirmIdentity(activity, context, "Delete wallet")
128
+ val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
129
+ metadata.remove(id)
130
+ NativeWalletStore.saveMetadata(context, metadata)
131
+ NativeWalletStore.deleteMnemonic(context, id)
132
+ }
72
133
  }
73
134
 
74
135
  // Triggers the native biometry/device-credential prompt, then signs entirely in-process.
@@ -81,6 +142,25 @@ class ChainberryTrustWalletCoreModule : Module() {
81
142
  val cipher = NativeWalletStore.authenticateForExistingWallet(activity, context, id, "Sign transaction")
82
143
  val mnemonic = NativeWalletStore.loadMnemonic(context, id, cipher)
83
144
  val wallet = HDWallet(mnemonic, "")
145
+ // Backfill any addresses missing from metadata (chains added after wallet was created).
146
+ // Runs silently after the biometric gate — no extra prompt needed.
147
+ val metadata = NativeWalletStore.loadMetadata(context).toMutableMap()
148
+ @Suppress("UNCHECKED_CAST")
149
+ val storedAddresses = (metadata[id] as? Map<String, String>)?.toMutableMap()
150
+ if (storedAddresses != null) {
151
+ var changed = false
152
+ for (chainEntry in ChainKey.entries) {
153
+ val key = chainEntry.name.lowercase()
154
+ if (!storedAddresses.containsKey(key)) {
155
+ storedAddresses[key] = ChainSigner.addressForChain(wallet, chainEntry, isTestnet)
156
+ changed = true
157
+ }
158
+ }
159
+ if (changed) {
160
+ metadata[id] = storedAddresses
161
+ runCatching { NativeWalletStore.saveMetadata(context, metadata) }
162
+ }
163
+ }
84
164
  val result = ChainSigner.sign(chainKey, wallet, unsignedTx, isTestnet)
85
165
  val response = mutableMapOf<String, Any>("signedTx" to result.signedTx)
86
166
  result.meta?.let { response["meta"] = it }