| Audited | horizontalsystems/unstoppable-wallet-android at commit b6ced7d1b2eceae84488bcba90e569711c38000e |
|---|---|
| Date | 11 October 2026 |
| How it ran | cloud session, full audit, Standard review |
| Verdict after review | Pass with notes (rule: Fail if a High finding remains after review, otherwise Pass with notes) |
Each finding keeps the number it has in the audit report. The rating shown first is the one after review; the first automated rating is listed with it. 30 findings were first rated Medium or High; 3 of them are Medium or High after review. Text marked "From the report" is quoted from the audit report; fixes are suggestions and were not tested. Code locations link to the file and line at the audited commit. 38 findings were not individually re-checked and are shown as first rated.
Medium after review (3)
From the report
if (tmpRawTransaction != null) {
fullTransaction = adapter.send(tmpRawTransaction)
The base64 message from the swap API is signed as it is. Nothing checks that the fee payer and token authority are the user's wallet, or that the destination, mint and amount match the quote the user approved. A compromised or faulty provider response could move funds elsewhere.
Decode the message with the instruction parser the WalletConnect summary already has. Require the payer and authority to be the active wallet and the transfers to match the quote. Refuse unknown programs and instructions.
Review: if (tmpRawTransaction != null) { fullTransaction = adapter.send(tmpRawTransaction); the bytes come from Base64.decode(message) in USwapProvider.kt:749 (and AllBridge:393) with no decode step, and the confirm state has fields = listOf(). The decoder the report points to exists (WCSolanaTxSummary) but is not called here. Trust is in the project's own swap API over https, so exploitation needs backend or provider compromise; that keeps it MEDIUM, not HIGH. Same root cause as #59.
Upstream: No exact upstream report; related items: #9542 (PR, merged), #9641 (PR, merged).
walletkit/src/main/java/io/horizontalsystems/walletkit/modules/multiswap/providers/USwapProvider.kt:710 (also walletkit-chain-stellar/.../StellarSwapProvider.kt:158 and walletkit-chain-ton/.../SendTransactionServiceTon.kt:70)From the report
val transactionData = EvmTransactionData(
to = signable.to ?: throw IllegalStateException("No tx `to`"),
value = BigInteger((signable.value ?: "0x0").stripHexPrefix(), 16),
Only the THORChain memo destination is checked. For the other chains, the destination, value, operations and source account are taken from the API as they are. A compromised or faulty provider can have the wallet sign a transfer elsewhere.
Before signing, decode the payload and check that the value is at most the quoted input, the target is a known router or deposit address, and the recipient matches. Reject unexpected operations.
Review: EvmTransactionData(to = signable.to ..., value = ..., input = ...) is taken straight from the response; the only server-output check in USwapProvider is ThorChainSwapMemo.requireDestination at line 622. Stellar WithTransactionEnvelope(xdr) and TON adapter.sign(request) behave the same. Same trust model and rating as #1; remediate together.
Upstream: No exact upstream report; related items: #9641 (PR, merged).
From the report
viewModelScope.launch(Dispatchers.Default) {
val boc = transactionSigner.sign(sendRequestEntity, tonWallet)
tonKitWrapper?.tonKit?.send(boc)
Signing and sending errors are uncaught, so the app crashes. If the kit is null, the send is skipped but the dApp is still told the request was approved. The UI shows success before anything is sent.
Make confirm a suspend function and await it. Catch errors and reject the request toward the dApp. Approve only after the send succeeds.
Review: confirm() does viewModelScope.launch(Dispatchers.Default) { val boc = transactionSigner.sign(...); tonKitWrapper?.tonKit?.send(boc); tonConnectKit.approve(...) } and returns at once, so TonConnectSendRequestScreen.kt:103-110 shows "Done" before anything is sent, and its try/catch cannot see exceptions from the launched job. A network failure in send is an uncaught exception in a coroutine (app crash) and the dApp is never answered. Realistic trigger (offline send) on a send path. The null-wrapper "approve without send" variant is less likely since the wrapper is set during load (line 137).
Upstream: No matching report among the project's newest 1,000 issues and pull requests (11 October 2026).
Low after review (65)
From the report
val tokenInfo = tokenProvider.getTokenInfo(reference)
...
decimals = tokenInfo.decimals
The send path scales amounts with these decimals. A wrong or spoofed API answer leads to mis-scaled transfers and a misleading symbol.
Read decimals and the token program owner from the mint account on chain. Use the API data only for display.
Review: decimals = tokenInfo.decimals from the Jupiter-backed TokenProvider, but only for a custom token the user adds by mint address. Wrong decimals would mis-scale the display consistently (balance is read in raw units); spoofing needs a compromised API. Reading the mint account on chain is the better design, but this is hardening.
From the report
3 -> if (data.size >= 9) {
transfers.add(Transfer(false, u64LE(1), null, payer = accountAt(2), destination = accountAt(1)))
Classic and Token-2022 programs are treated the same, the mint is never resolved, and plain Transfer amounts are shown as raw base units. A dApp can present a worthless token as an ordinary transfer row.
Resolve the mint (TransferChecked carries it; for Transfer, look up the source account's mint). Show the symbol and decimals only for known mints, and show the mint address otherwise. Check program, mint and instruction shape together.
Review: Quoted code is right (Transfer(false, u64LE(1), null, ...), amount shown as raw transfer.amount.toString()). But this is a best-effort display aid on an unverified dApp request: unknown instructions, hidden recipients and unreadable payloads get an explicit Warning.* banner (lines 166-167). A missing symbol is a UX gap, not a defect that moves funds.
From the report
val expectedBuyAmountOrZero: BigDecimal
get() = expectedBuyAmount ?: BigDecimal.ZERO
When the server leaves out the expected amount, the confirm page shows that the user will receive 0, and route ranking treats the route as worth 0. A real zero and a missing value cannot be told apart.
Keep the value nullable and reject routes or quotes that lack it.
Review: get() = expectedBuyAmount ?: BigDecimal.ZERO is real, used at lines 342, 375, 625. Effect is a "0" on the confirm page or a route ranked last when the server omits the field; the user sees the zero, nothing is signed against it. Display and ranking only.
walletkit/src/main/java/io/horizontalsystems/walletkit/core/managers/EncryptDecryptManager.kt:65From the report
fun generateMac(derivedKey: ByteArray, cipherText: ByteArray): ByteArray {
System.arraycopy(derivedKey, 16, result, 0, 16)
AES-CTR plus a Keccak MAC are combined by hand, and the MAC key overlaps the AES key. No break was demonstrated, but this design is easy to get wrong.
Use AES-256-GCM or ChaCha20-Poly1305 for new backups, or at least split the derived key into separate encryption and MAC keys. Keep the old format read-only.
From the report
val message = it.message?.takeIf { m -> m.isNotBlank() }
?: it::class.java.simpleName.ifBlank { "Error" }
Users see class names or English technical text. The same happens in Nav3.kt (lines 285 and 329) and WCSessionViewModel.setError.
Map known failures to localised messages and send the detail to the log.
app/build.gradle.kts:26From the report
targetSdk = libs.versions.compileSdk.get().toInt()
The app targets API 37, and users can add self-hosted nodes. No manifest or source file mentions ACCESS_LOCAL_NETWORK (all manifests and Kotlin sources were searched), so connections to LAN nodes will fail on newer Android.
Declare the permission, request it before connecting to a private-range host, and explain the failure if the user declines.
From the report
val amount = assetAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO
A malformed payment request produces a zero-value transaction that is still offered for signing.
Show an error when the amount is missing, non-numeric or not positive.
From the report
suspend fun handle(scannedText: String, closeAppOnResult: Boolean = false) {
val dAppRequest = kit.readData(scannedText)
A re-delivered intent opens a second connect prompt for the same request.
Keep a bounded set of handled request ids and ignore repeats.
walletkit-chain-zcash/src/main/java/io/horizontalsystems/walletkit/chain/zcash/ZcashAddressSupport.kt:49 (same pattern in walletkit-chain-zano/.../ZanoAddressSupport.kt:32)From the report
if (cache.containsKey(value)) return true
...
return cache[value]!!
The cache is an unsynchronised map, and parseAddress depends on an earlier isSupported call. Concurrent calls, or a call order that changes, can throw a NullPointerException.
Use a ConcurrentHashMap and a single lookup with a null check. Resolve the name again instead of using !!.
walletkit/src/main/java/io/horizontalsystems/walletkit/modules/market/tvl/TvlViewItemFactory.kt:25From the report
if (!cache.containsKey(item.hashCode())) {
Two items whose hashes collide return the same view item.
Key the cache on a stable id and use getOrPut.
walletkit/src/main/java/io/horizontalsystems/walletkit/core/managers/LocalStorageManager.kt:130From the report
get() = preferences.getBoolean("chartIndicatorsEnabled", false)
Keys mix camelCase, kebab-case and snake_case as bare strings, which makes collisions and typos easy.
Define namespaced key constants in one place. Migrate stored values if you rename keys.
From the report
import androidx.compose.material.MaterialTheme
Material 3 components read defaults that the Material 2 theme never sets.
Bridge the app colours into a Material 3 theme, or finish the migration.
From the report
scrollToTopAfterUpdate = true
viewModel.onSelectSortingField(selected)
A coroutine is launched inside the LazyColumn content builder, and a flag is cleared during composition. The same pattern appears in several market screens.
Scroll from the click handler, or from a LaunchedEffect keyed on the sort change.
walletkit/src/main/java/io/horizontalsystems/walletkit/modules/coin/analytics/ui/Components.kt:238From the report
OverallScore.Excellent -> Color(0xFF05C46B)
OverallScore.Good -> Color(0xFFFFA800)
The same colours are repeated in OverallScoreInfoPage.kt, and the copies can drift apart.
Move them to named theme tokens.
From the report
<item name="android:actionMenuTextAppearance">@style/Headline2</item>
The night theme uses the AppCompat attribute without android:, so menu text styling can differ between the two themes. Both files also hard-code #6E7899.
Use the same attribute in both files and a colour resource.
From the report
val nightMode = when (themeType) {
ThemeType.Light -> AppCompatDelegate.MODE_NIGHT_NO
The same mapping exists in core/App.kt, and the two copies can diverge.
Extract one ThemeType.toNightMode() function and call it from both places.
From the report
override fun import(accounts: List<Account>) {
...
_accountsFlow.tryEmit(accounts)
The parameter shadows the full account list. Subscribers see only the restored subset. For example, "all backed up" can be computed as true while older accounts that were never backed up still exist.
Emit this.accounts, the full cache, after the import.
Review: Real: _accountsFlow.tryEmit(accounts) in import(accounts: List<Account>) shadows the property and emits only the imported list. Consumers (BackupManager.allBackedUpFlow, ManageAccountsViewModel) show a wrong subset until the next emission or until the activeAccountStateFlow collector re-reads accountManager.accounts. Transient UI state, no stored data affected.
walletkit/src/main/java/io/horizontalsystems/walletkit/core/managers/TransactionAdapterManager.kt:45From the report
val currentAdapters = this.adaptersMap.toMutableMap()
this.adaptersMap.clear()
While the map is being refilled, getAdapter() returns nothing. Transaction lists can render empty, and spam scoring runs without context. The main adapter manager already swaps its map atomically.
Build a new map and swap it in as a single step.
Review: Real: this.adaptersMap.clear() then refill in initAdapters, so getAdapter() can return null in that window. The window is the length of a synchronous loop; the map is a ConcurrentHashMap, so nothing throws. The "spam scoring runs without context" consequence is not shown in the code.
walletkit-chain-xrp/src/main/java/io/horizontalsystems/walletkit/core/managers/XrpKitManager.kt:108 (same in walletkit-chain-thorchain/.../ThorchainKitManager.kt:119)From the report
private fun handleUpdateNetwork() {
stop()
_kitStoppedFlow.tryEmit(Unit)
getKitWrapper is synchronised and ends in kitWrapper!!, but stop() can set the wrapper to null concurrently, which throws a NullPointerException. The THORChain collector is also not exception-guarded, so one throw cancels the shared scope for every later kit.
Make handleUpdateNetwork and stop synchronised, as the sibling managers are. Wrap the THORChain collector body in try/catch.
Review: handleUpdateNetwork() calls unsynchronised stop() while getKitWrapper is @Synchronized and ends in kitWrapper!!, so an NPE needs the user to change the RPC provider in the instant another thread is in getKitWrapper. XRP's collector is wrapped in try/catch; only THORChain's sourceJob is not, and stop() rarely throws. Theoretical race on a rare user action.
walletkit/src/main/java/io/horizontalsystems/walletkit/core/managers/MarketFavoritesManager.kt:35From the report
fun addAll(coinUids: List<String>) {
dao.insertAll(coinUids.map { FavoriteCoin(it) })
add() updates the manual order but addAll(), used by backup restore, does not. Restored coins jump to the top in Manual sort.
Route all favourite writes through one method that updates both the database and the order list.
Review: Confirmed: add() updates marketFavoritesManualSortingOrder, addAll() (used by BackupProvider.kt:400) does not. Result is the ordering of restored watchlist entries in Manual sort mode. Cosmetic.
From the report
blockchainSettingsStorage.save(syncSource.url, blockchainType)
...
blockchainSettingsStorage.saveMoneroNode(node.url)
The managers that restart kits and emit change events are bypassed. Running EVM, Tron and Monero kits keep the old source until the app restarts.
Call the sync-source and node manager save methods instead of the storage layer.
Review: Confirmed: blockchainSettingsStorage.save(...) and saveMoneroNode(...) bypass the managers, so running kits keep the old source until restart. Only affects which node is selected after restoring a full backup with the CustomRpc section; the custom nodes themselves are saved via the manager (line 289). Stale selection, no loss.
walletkit/src/main/java/io/horizontalsystems/walletkit/core/managers/ConnectivityManager.kt:58From the report
connectivityManager.registerNetworkCallback(NetworkRequest.Builder().build(), callback)
The same callback, with its activeNetworks list, is registered again on every foreground. Networks that disappeared while it was unregistered are never removed, so the app can report "connected" while offline.
Clear the list, or create a new callback, in willEnterForeground.
Review: Confirmed: activeNetworks in the single callback instance survives unregister/register, and onLost is missed while backgrounded. hasValidInternet is also never cleared on onLost, so the offline indicator can be wrong. Effect is a stale connectivity banner; sync code retries on its own.
From the report
while (isActive) {
syncPending()
delay(POLL_INTERVAL_MS)
A stuck record is requested forever at the same rate, including after failures.
Add per-record backoff, an age cap for stale records, and foreground gating.
From the report
scannedTransactionStorage.getScannedTransaction(transactionHash)?.let {
return it.isSpam
}
Changed thresholds or limits take effect only through hand-written migrations that wipe the table.
Store a scorer version with each row and rescore rows from older versions.
From the report
if (record.trackingHandle != null) return
Ownership is checked before a network call, and the status is written unconditionally afterwards. A send screen's concurrent status change can be overwritten.
Move the condition into the UPDATE's WHERE clause.
From the report
if (activeAccount?.id != activeAccountId) {
If the cached Account object is replaced but its id is unchanged, the stale object stays active.
Refresh activeAccount from the cache whenever the cache is rebuilt.
From the report
if (record.spam && spamManager.hideSuspiciousTx) return@forEach
The condition is repeated in TokenTransactionsService and TransactionFilterService, and a new reader can forget it.
Expose one isHidden(record) predicate and call it everywhere.
From the report
val type: String,
val words: SecretList?,
val key: SecretString?,
The type determines which secret column must be set, but nothing enforces or verifies this.
Document the invariant and add a startup or post-migration check.
From the report
val spamScore: Int,
val blockchainType: BlockchainType,
val address: String?
Address lookups depend on spam rows having an address, but the write path can store null.
Guard the write and add a verification query.
walletkit/src/main/java/io/horizontalsystems/walletkit/core/storage/ScannedTransactionDao.kt:25From the report
@Query("SELECT * FROM ScannedTransaction WHERE spamScore >= 7 AND address = :address LIMIT 1")
If the threshold constant changes, the queries and the scorer disagree.
Pass the threshold as a query parameter.
walletkit/src/main/java/io/horizontalsystems/walletkit/core/managers/LocalStorageManager.kt:230From the report
override var baseBitcoinProvider: String?
get() = preferences.getString(BASE_BITCOIN_PROVIDER, null)
Several accessors, including an unused authToken slot, have no callers and suggest features that do not exist.
Delete them and their keys.
walletkit/src/main/java/io/horizontalsystems/walletkit/modules/multiswap/SwapConfirmViewModel.kt:362 (also modules/crosspay/CrossPayConfirmViewModel.kt:296 and modules/privatesend/PrivateSendConfirmViewModel.kt:288)From the report
val result = sendTransactionService.sendTransaction(swapDefenseState.mevProtectionEnabled)
saveSwapRecord(result)
The database write and the recent-address write run after the funds are sent, with no error boundary. If either throws, the user sees a failure and can send again, which broadcasts a second transaction. SendV2ConfirmViewModel already guards this step.
Wrap post-broadcast bookkeeping in try/catch that logs the error and still returns the result.
Review: Confirmed: sendTransaction(...) is followed by saveSwapRecord(result) and recentAddressManager.setRecentAddress with no try/catch, while SendV2ConfirmViewModel.kt:284-294 guards the same step. The throw needs a local Room insert to fail after a successful broadcast (disk full, closed DB). The consequence is nasty (user may resend) but the trigger is very rare. Worth the five-line fix; not MEDIUM.
walletkit/src/main/java/io/horizontalsystems/walletkit/modules/contacts/ContactsRepository.kt:200From the report
FileOutputStream(file).use { fos ->
fos.bufferedWriter().use { bw ->
bw.write(asJsonString)
The file is truncated before the data is serialised. A crash, a full disk or an exception during serialisation leaves an empty or partial address book. The UI has already shown the change as saved, and the error is only logged.
Serialise first, write to a temporary file, then rename it atomically over the original (or use AtomicFile). Report write failures to the caller.
Review: FileOutputStream(file).use { ... bw.write(asJsonString) } truncates before serialising, and the catch only logs (writeToFile() error). The loss needs a kill or I/O error inside a sub-millisecond write; the data is an address book, and a full backup restores it. Combined with #35, a ConcurrentModificationException inside asJsonString would leave an empty file, still rare.
walletkit/src/main/java/io/horizontalsystems/walletkit/modules/contacts/ContactsRepository.kt:31From the report
private var contactsMap: MutableMap<String, Contact> = mutableMapOf()
save and delete mutate the map on the caller thread while the writer thread iterates it. The startup load can also replace it. Results include a ConcurrentModificationException inside the hidden write, or a contact saved during startup being lost.
Confine the map to the single dispatcher or guard it with a Mutex, and make save wait for the initial load.
Review: contactsMap is a plain mutableMapOf mutated by save/delete on the caller thread and iterated by writeToFile on the single writer thread. The race is real but needs two edits in the same millisecond or an edit during the startup read; the exception is caught inside the write.
walletkit/src/main/java/io/horizontalsystems/walletkit/modules/qrscanner/QRScannerActivity.kt:164From the report
val bitmap = context.contentResolver.openInputStream(uri)
?.use { BitmapFactory.decodeStream(it) } ?: return null
val intArray = IntArray(bitmap.width * bitmap.height)
A large photo needs hundreds of MB. The resulting OutOfMemoryError is not caught by catch (Exception), so the app crashes or freezes.
Read the image bounds first, downsample to about 2048 px, decode off the main thread, and handle out-of-memory errors.
Review: Confirmed: BitmapFactory.decodeStream(it) plus IntArray(bitmap.width * bitmap.height) on a user-picked gallery image, in the result callback. A 48 MP photo can OOM, but it is the user's own image and the result is an app crash on a convenience feature.
walletkit/src/main/java/io/horizontalsystems/walletkit/modules/importwallet/ImportWalletPage.kt:99From the report
val jsonString = br.readText()
//validate json format
BackupFileValidator().validate(jsonString)
A very large file can freeze the UI. The same pattern appears in the backup manager and contacts screens.
Cap the size, read and parse on a background dispatcher, and parse once.
From the report
class USwapProvider(
At 1,209 lines, the class builds transactions for nine chains in the core module, although chain plugin modules exist.
Split out the API and DTOs, and move per-chain transaction construction behind the chain plugin interface.
From the report
class TransactionViewItemFactory(
At 1,457 lines, the factory holds the XRP, Stellar, TON, Solana and Tron converters in core, away from their chain modules.
Move one converter per chain into each chain module, and keep the factory as a dispatcher.
From the report
fun handleDeepLink(uri: Uri) {
The view model depends on an Android platform type, which makes it harder to test.
Convert the Uri to a String or parsed values in the Activity.
walletkit/src/main/java/io/horizontalsystems/walletkit/modules/send/v2/SendV2ConfirmViewModel.kt:263From the report
fun onClickSend() {
viewModelScope.launch(Dispatchers.IO) {
send()
Only the button composable stops a double send. If a second call reaches the view model, it would broadcast a second transaction.
Track the in-flight Job and return early while it is active.
From the report
suspend fun send(): SendTransactionResult = withContext(serviceDispatcher) {
The two 60-line blocks are near-identical, so a fix such as finding 33 has to be made twice.
Extract one shared send-with-record helper.
From the report
val qrScannerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
The copies have already drifted: only one of them calls onScanQR.
Extract one reusable QR-scan button composable.
From the report
override val spamCoinValueLimits: Map<String, BigDecimal> = mapOf(
"XLM" to BigDecimal("0.1"),
Tuning the spam limits or the swap fee requires a new release.
Consider serving a versioned rule config from the backend.
From the report
if (!tokenAutoEnableManager.autoEnableTokensOnReceive) return
val enabledWallets = newAssets.map { asset ->
Seven chain account managers repeat the same logic. TON drops blacklisted jettons, but Stellar and Tron enable any received asset and take its code from the sender. Someone can send a fake "USDT", and it appears in the user's wallet.
Extract one shared auto-enable helper with a per-chain acceptance hook, and apply the scam filtering on every chain.
Review: Confirmed that the per-chain managers are copies and only TON filters (it.verification != JettonVerificationType.BLACKLIST). But the Stellar claim is weak: the code walks operation.payment assets, and a Stellar payment of a non-native asset only succeeds if the account already has a trustline. Tron only enables TRC-20 tokens with balance > 0 and has its own suspicious-token handling (lines 132-170). Fake-token noise in the balance list, not a fund-loss path.
docker/test.sh:139From the report
builtApk=$workDir/app/app/build/outputs/apk/release/app-release-unsigned.apk
The release process depends on this script. The build now uses product flavours and a newer JDK, while the script installs JDK 11, runs :app:assembleRelease and expects an output path that no longer exists. The verification step cannot succeed.
Update the task name, output path and JDK, and run the script in CI on each release.
Review: Confirmed stale: docker/test.sh:139 expects app/build/outputs/apk/release/app-release-unsigned.apk, installs openjdk-11-jdk and runs :app:assembleRelease, while the build has product flavours and the workflows use JDK 17. RELEASE.md:72 still tells operators to run it. It is a third-party verification helper (walletscrutiny image), not shipped, and docker/build-apk.sh is the current build path.
.github/workflows/deploy_release.yml:75From the report
run: ./gradlew clean assembleCiRelease --stacktrace --no-daemon
No workflow runs ./gradlew test or lint (searched .github/).
Add a test job and make the build and publish jobs depend on it.
From the report
import io.horizontalsystems.walletkit.modules.multiswap.sendtransaction.toEvmTransactionData
Move the helper into the shared module and drop the dependency.
return@let in deeply nested lambdasFrom the report
value = getFormattedValue(data.points.lastOrNull()?.tvl ?: return@let, currency),
A later edit can silently return from the wrong lambda and drop an analytics block.
Use explicit labels, or extract each block into a function.
From the report
Log.e("AAA", "billingClient.isReady: ${billingClient.isReady}")
Remove them or use debug level with a descriptive tag. The same applies in AdapterFactory.kt and ResultEventBus.kt.
From the report
Log.w("XRate", "Could not fetch xrate for ${key.coinUid}:${key.timestamp}, ${e.javaClass.simpleName}:${e.message}")
Remember failed keys for a cool-down period and log one summary per sync.
From the report
// Mirrors the registration in App.onCreate.
ChainRegistry.register(TronChainPlugin())
The test never registers the EVM plugins that the app does, so EVM regressions are not caught.
Call the app's real registration function from the test.
.github/workflows/build_apk.yml:66From the report
- name: Upload to GitHub Release
The release document requires .sha256 and .md5 assets, but nothing produces them (searched .github/ and docker/).
Generate and upload checksums after signing.
docker/build-apk.sh:83From the report
cp "${WORK_DIR}/${BUILT_APK_FILE}" "${PWD}/unstoppable_wallet_google_play_${TAG}.apk"
Check that the target does not exist (cp -n), or abort if it does.
app/build.gradle.kts:120 (also lines 47, 72–73, 121–142 and 154–176)From the report
buildConfigFieldString("TWITTER_BEARER_TOKEN", "AAAA…")
buildConfigFieldString("CHAINALYSIS_API_KEY", "928b…")
val uswapApiKeyAndroid = "a32d…"
The repository contains a Twitter bearer token, explorer and RPC keys, Chainalysis, HashDit, 1inch, swap-partner keys and the test keystore password. They stay in git history. Anyone can use them to exhaust quotas or impersonate the app to partners.
Rotate the keys. Inject them from CI secrets or an untracked local.properties. Proxy the paid or private ones through a backend, and restrict the rest by package and quota at each provider.
Review: Confirmed: real-looking tokens in app/build.gradle.kts lines 120-176 (Twitter bearer, Etherscan, Alchemy, Chainalysis, HashDit, 1inch, uswap key) and storePassword = "testKeystore123" for a committed test keystore. Rotate the paid ones; LOW.
walletkit/src/main/java/io/horizontalsystems/walletkit/modules/pin/core/AppLockManager.kt:55From the report
val timeInBackground = System.currentTimeMillis() - lastBackgroundTime
A negative elapsed time never reaches the lock interval. Someone holding the unlocked phone can set the clock back and reopen the wallet without the PIN.
Lock when the elapsed time is negative, and base the check on SystemClock.elapsedRealtime() plus a boot check.
Review: Confirmed: val timeInBackground = System.currentTimeMillis() - lastBackgroundTime goes negative after a clock change and >= intervalMillis is then false. The attacker must already hold the unlocked phone and be able to change the system clock, a physical-access scenario against a control that sits behind the device lock. Easy fix (elapsedRealtime).
From the report
val kdfParams = crypto.kdfparams
val key = EncryptDecryptManager.getKey(passphrase, kdfParams) ?: throw RestoreException.EncryptionKeyException
A crafted backup with a huge n or r exhausts memory. getKey only catches Exception, so the app crashes during restore.
Reject parameters outside fixed limits before deriving the key.
Review: Confirmed: kdfParams.n/r/p come straight from the file into SCrypt.generate; catch (e: Exception) does not catch OutOfMemoryError. The user must choose to import a crafted file, and the result is a crash during restore.
From the report
activeSubscriptions.clear()
if (!billingClient.isReady) {
When billing is disconnected on resume, paying users lose premium features until a later successful query. The list is also changed on a background thread while the UI reads it. In addition, the result of launchBillingFlow (line 250) is ignored. If it fails, the purchase coroutine never resumes.
Build a new list and swap it in only after a successful query. Resume with a failure when the launch result is not OK.
Review: Confirmed: activeSubscriptions.clear() precedes the isReady check (lines 94-95) and launchBillingFlow's result is unused (line 250). But when not ready the code calls startConnection(), whose onBillingSetupFinished refetches, so the premium loss is brief. Paid-feature UX, not security; Play flavor only.
From the report
val transaction = stellarKit.getTransaction(xdr)
The XDR is decoded in createState(), outside the guarded action construction, so a connected dApp can crash the app. The sign-and-submit action and the fee estimation path have the same problem.
Decode the XDR in the constructor so that the existing try/catch handles failures, and bound its length.
Review: Confirmed: stellarKit.getTransaction(xdr) runs in createState(), which is first evaluated when WCRequestViewModel is built (wcAction.stateFlow.value), outside the try/catch in WCManager.getActionForRequest. A dApp the user already connected can crash the app with a bad XDR. Crash only; the dApp session is user-approved. I did not verify whether the pending request survives a relaunch.
From the report
nodeManager.addMoneroNode(url, username, password, true)
The wallet trusts data from any node the user adds, including a hostile one.
Add custom nodes as untrusted by default. Let users opt in explicitly, with a warning.
Review: nodeManager.addMoneroNode(url, username, password, true) is real, but the URL must be https or an onion http host (lines 53-55), the screen is a user-initiated "add my own node", and the node list has an explicit trust toggle and bottom sheet (MoneroNetworkPage.kt:191, MoneroNodeTrustBottomSheet.kt). A default-policy choice, not a defect.
From the report
val hasRequiredProtocol = scheme == "https" || scheme == "http"
Node traffic, including alias answers, can be read or changed on the network. Monero and Zcash already require https or onion.
Allow http only for .onion hosts.
Review: scheme == "https" || scheme == "http" is accepted by the add-node validator, but network_security_config.xml sets cleartextTrafficPermitted="false" for everything except .onion and six listed Monero hosts, and ZanoAliasResolver uses OkHttp, which honours that policy. So plain http to a clearnet Zano node is refused for alias calls; sync transport is inside zano-kit and not verifiable here. Validator inconsistency with Monero, not an open cleartext path.
walletkit-chain-zano/src/main/java/io/horizontalsystems/walletkit/chain/zano/ZanoAliasResolver.kt:21From the report
val baseUrl = zanoNodeManager.currentNode.host.trimEnd('/')
A hostile or intercepted node can return any address for an alias, and that address goes straight into the send flow.
Resolve aliases only through trusted https nodes, validate the returned address, and show it in full for confirmation.
Review: zanoNodeManager.currentNode.host is what the user selected (defaults are https project/org nodes). Trusting the selected node for name resolution is the usual model, and the resolved address then goes through ZanoAddressValidator. The attack needs the user to pick a hostile node.
From the report
String(message.hexStringToByteArray())
The hex message is converted to a String, and WCRequestEvmViewModel.kt:116 signs that String's UTF-8 bytes. For binary payloads such as hashes, the signature covers different bytes from the ones requested, so it is invalid.
Sign the original decoded bytes. Use the String only for display, with a hex fallback.
Review: Confirmed: String(message.hexStringToByteArray()) then sessionRequest.param.toByteArray() in WCRequestEvmViewModel. For UTF-8 text (SIWE and nearly all personal_sign traffic) the round trip is lossless; only non-UTF-8 binary payloads sign different bytes, giving an invalid signature rather than a wrong-intent one. Compatibility bug.
From the report
} catch (ex: Exception) {
null
}
If decrypting a stored secret fails, the seed account vanishes from the UI with no log, while isAccountsEmpty still counts it. Users may think their wallet is gone.
Log and surface rows that cannot be decoded, and keep them recoverable.
Review: The catch (ex: Exception) { null } at line 84 is real and silent. But the report's mechanism is wrong: secret decryption happens in the Room type converter (DatabaseConverters.decryptSecretString) while dao.getAll() runs, outside this try block, so a decrypt failure propagates instead of being swallowed. What is swallowed are malformed rows (bad key parse, null secret, unknown type), which is data corruption or a downgrade case.
From the report
delete(walletsToReAdd)
save(walletsToReAdd)
If the process dies between the two writes, the enabled wallets for that chain are lost. The one-slot flow that drops older values can also swallow the intermediate list, so adapters are not rebuilt.
Use one transaction plus an explicit rebuild signal.
Review: delete(walletsToReAdd); save(walletsToReAdd) in reloadWallets is two storage writes, but the window is milliseconds and triggers only on RPC or restore-mode changes. The dropped-emission half is theoretical: AdapterManager.initAdapters reuses adapters for equal wallets, so a lost intermediate emission would skip a rebuild, but the two emissions are separated by Room writes, so the collector normally consumes the first. Not verified at runtime.
walletkit/src/main/java/io/horizontalsystems/walletkit/modules/contacts/ContactsRepository.kt:191From the report
addresses = contactJson.addresses.mapNotNull { addressJson ->
marketKit.blockchain(addressJson.blockchain_uid)?.let { ContactAddress(it, addressJson.address) }
The load drops addresses whose blockchain is unknown at that moment. A failed read leaves the list empty. The next save then overwrites the file with what remains.
Keep unresolved entries as raw data. Do not overwrite the file after a partial or failed read.
Review: mapNotNull { marketKit.blockchain(uid)?.let { ... } } silently drops addresses for unknown blockchain uids and the next save persists the reduction (a failed read also leaves an empty map). It needs an unknown or removed blockchain uid or a corrupt file; on the happy path nothing is lost.
Info after review (1)
From the report
object Translator {
Note: Strings resolve only from bundled resources. No override mechanism was found. If this is intentional, record the decision.