@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.
- package/ChainberryTrustWalletCoreModule.podspec +3 -2
- package/README.md +14 -5
- package/android/src/main/java/com/chainberry/trustwalletcore/ChainSigning.kt +368 -10
- package/android/src/main/java/com/chainberry/trustwalletcore/ChainberryTrustWalletCoreModule.kt +86 -6
- package/android/src/main/java/com/chainberry/trustwalletcore/NativeWalletStore.kt +92 -0
- package/android/src/test/java/com/chainberry/trustwalletcore/NativeWalletStoreTest.kt +70 -0
- package/ios/ChainSigning.swift +409 -33
- package/ios/ChainberryTrustWalletCoreModule.swift +115 -10
- package/ios/ConformanceTests/AddressDerivationConformanceTests.swift +39 -0
- package/ios/ConformanceTests/SigningConformanceTests.swift +295 -0
- package/ios/NativeWalletStore.swift +56 -0
- package/package.json +1 -1
- package/src/index.ts +18 -4
|
@@ -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, '
|
|
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'
|
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
|
|
|
@@ -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 {
|
|
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;
|
|
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
|
|
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
|
-
|
|
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()
|
|
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,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
|
|
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 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 }
|
package/android/src/main/java/com/chainberry/trustwalletcore/ChainberryTrustWalletCoreModule.kt
CHANGED
|
@@ -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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 }
|