Sample audits · Open-source projects and documentation, eight more

horizontalsystems/unstoppable-wallet-android

An open-source multi-chain wallet for Android.

Auditedhorizontalsystems/unstoppable-wallet-android at commit b6ced7d1b2eceae84488bcba90e569711c38000e
Date11 October 2026
How it rancloud session, full audit, Standard review
Verdict after reviewPass with notes (rule: Fail if a High finding remains after review, otherwise Pass with notes)
0
High after review
3
Medium after review
65
Low after review
1
Info after review
0
excluded on review
How to read this page

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)

1. Medium Raw Solana transactions from swap providers are signed without decoding them
First rating: Medium · Reviewed rating: Medium · Review: confirmed
From the report
Evidence
if (tmpRawTransaction != null) {
    fullTransaction = adapter.send(tmpRawTransaction)
Why it matters

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.

Suggested fix, not tested

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).

59. Medium Server-built EVM, Stellar and TON swap transactions are signed without checks
First rating: Medium · Reviewed rating: Medium · Review: confirmed
From the report
Evidence
val transactionData = EvmTransactionData(
    to = signable.to ?: throw IllegalStateException("No tx `to`"),
    value = BigInteger((signable.value ?: "0x0").stripHexPrefix(), 16),
Why it matters

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.

Suggested fix, not tested

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).

62. Medium TonConnect confirm can crash, and can approve a transaction it never sent
First rating: Medium · Reviewed rating: Medium · Review: confirmed
From the report
Evidence
viewModelScope.launch(Dispatchers.Default) {
    val boc = transactionSigner.sign(sendRequestEntity, tonWallet)
    tonKitWrapper?.tonKit?.send(boc)
Why it matters

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.

Suggested fix, not tested

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)

2. Low Custom SPL token decimals and symbol come from an off-chain API
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
val tokenInfo = tokenProvider.getTokenInfo(reference)
...
decimals = tokenInfo.decimals
Why it matters

The send path scales amounts with these decimals. A wrong or spoofed API answer leads to mis-scaled transfers and a misleading symbol.

Suggested fix, not tested

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.

3. Low WalletConnect Solana summary does not identify the token being transferred
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
3 -> if (data.size >= 9) {
    transfers.add(Transfer(false, u64LE(1), null, payer = accountAt(2), destination = accountAt(1)))
Why it matters

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.

Suggested fix, not tested

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.

4. Low A missing swap output amount is shown as zero
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
val expectedBuyAmountOrZero: BigDecimal
    get() = expectedBuyAmount ?: BigDecimal.ZERO
Why it matters

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.

Suggested fix, not tested

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.

5. Low Backup encryption is hand-assembled
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
fun generateMac(derivedKey: ByteArray, cipherText: ByteArray): ByteArray {
    System.arraycopy(derivedKey, 16, result, 0, 16)
Why it matters

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.

Suggested fix, not tested

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.

6. Low Raw exception text is shown to users
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
val message = it.message?.takeIf { m -> m.isNotBlank() }
    ?: it::class.java.simpleName.ifBlank { "Error" }
Why it matters

Users see class names or English technical text. The same happens in Nav3.kt (lines 285 and 329) and WCSessionViewModel.setError.

Suggested fix, not tested

Map known failures to localised messages and send the detail to the log.

7. Low Local-network permission is not declared for API 37
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
targetSdk = libs.versions.compileSdk.get().toInt()
Why it matters

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.

Suggested fix, not tested

Declare the permission, request it before connecting to a private-range host, and explain the failure if the user declines.

8. Low An unparseable payment amount becomes zero
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
val amount = assetAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO
Why it matters

A malformed payment request produces a zero-value transaction that is still offered for signing.

Suggested fix, not tested

Show an error when the amount is missing, non-numeric or not positive.

9. Low TonConnect deep links are not de-duplicated
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
suspend fun handle(scannedText: String, closeAppOnResult: Boolean = false) {
    val dAppRequest = kit.readData(scannedText)
Why it matters

A re-delivered intent opens a second connect prompt for the same request.

Suggested fix, not tested

Keep a bounded set of handled request ids and ignore repeats.

10. Low Name-resolution caches use check-then-get on a plain map
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
if (cache.containsKey(value)) return true
...
return cache[value]!!
Why it matters

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.

Suggested fix, not tested

Use a ConcurrentHashMap and a single lookup with a null check. Resolve the name again instead of using !!.

11. Low TVL view-item cache is keyed by hashCode
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
if (!cache.containsKey(item.hashCode())) {
Why it matters

Two items whose hashes collide return the same view item.

Suggested fix, not tested

Key the cache on a stable id and use getOrPut.

12. Low Preference keys have no naming scheme
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
get() = preferences.getBoolean("chartIndicatorsEnabled", false)
Why it matters

Keys mix camelCase, kebab-case and snake_case as bare strings, which makes collisions and typos easy.

Suggested fix, not tested

Define namespaced key constants in one place. Migrate stored values if you rename keys.

13. Low Material 2 theme root with Material 3 components
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
import androidx.compose.material.MaterialTheme
Why it matters

Material 3 components read defaults that the Material 2 theme never sets.

Suggested fix, not tested

Bridge the app colours into a Material 3 theme, or finish the migration.

14. Low Scroll-to-top is triggered from inside the list builder
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
scrollToTopAfterUpdate = true
viewModel.onSelectSortingField(selected)
Why it matters

A coroutine is launched inside the LazyColumn content builder, and a flag is cleared during composition. The same pattern appears in several market screens.

Suggested fix, not tested

Scroll from the click handler, or from a LaunchedEffect keyed on the sort change.

15. Low Raw colour literals duplicated across screens
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
OverallScore.Excellent -> Color(0xFF05C46B)
OverallScore.Good -> Color(0xFFFFA800)
Why it matters

The same colours are repeated in OverallScoreInfoPage.kt, and the copies can drift apart.

Suggested fix, not tested

Move them to named theme tokens.

16. Low Day and night themes set different attributes for the same item
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
<item name="android:actionMenuTextAppearance">@style/Headline2</item>
Why it matters

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.

Suggested fix, not tested

Use the same attribute in both files and a colour resource.

17. Low Theme-to-night-mode mapping exists in two places
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
val nightMode = when (themeType) {
    ThemeType.Light -> AppCompatDelegate.MODE_NIGHT_NO
Why it matters

The same mapping exists in core/App.kt, and the two copies can diverge.

Suggested fix, not tested

Extract one ThemeType.toNightMode() function and call it from both places.

18. Low Account import publishes only the imported accounts
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
override fun import(accounts: List<Account>) {
...
    _accountsFlow.tryEmit(accounts)
Why it matters

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.

Suggested fix, not tested

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.

19. Low Transaction adapter map is cleared and refilled in place
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
val currentAdapters = this.adaptersMap.toMutableMap()
this.adaptersMap.clear()
Why it matters

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.

Suggested fix, not tested

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.

20. Low XRP and THORChain kit managers are not synchronised on provider change
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
private fun handleUpdateNetwork() {
    stop()
    _kitStoppedFlow.tryEmit(Unit)
Why it matters

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.

Suggested fix, not tested

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.

21. Low Restored favourites are left out of the manual sort order
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
fun addAll(coinUids: List<String>) {
    dao.insertAll(coinUids.map { FavoriteCoin(it) })
Why it matters

add() updates the manual order but addAll(), used by backup restore, does not. Restored coins jump to the top in Manual sort.

Suggested fix, not tested

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.

22. Low Backup restore writes selected nodes directly to storage
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
blockchainSettingsStorage.save(syncSource.url, blockchainType)
...
blockchainSettingsStorage.saveMoneroNode(node.url)
Why it matters

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.

Suggested fix, not tested

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.

23. Low Connectivity state keeps networks that were lost while the app was in the background
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
connectivityManager.registerNetworkCallback(NetworkRequest.Builder().build(), callback)
Why it matters

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.

Suggested fix, not tested

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.

24. Low Pending swap records are polled every 30 s with no limit
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
while (isActive) {
    syncPending()
    delay(POLL_INTERVAL_MS)
Why it matters

A stuck record is requested forever at the same rate, including after failures.

Suggested fix, not tested

Add per-record backoff, an age cap for stale records, and foreground gating.

25. Low Spam verdicts are cached by transaction hash only
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
scannedTransactionStorage.getScannedTransaction(transactionHash)?.let {
    return it.isSpam
}
Why it matters

Changed thresholds or limits take effect only through hand-written migrations that wipe the table.

Suggested fix, not tested

Store a scorer version with each row and rescore rows from older versions.

26. Low Swap status is written by two parties without an ownership check in the update
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
if (record.trackingHandle != null) return
Why it matters

Ownership is checked before a network call, and the status is written unconditionally afterwards. A send screen's concurrent status change can be overwritten.

Suggested fix, not tested

Move the condition into the UPDATE's WHERE clause.

27. Low Active account change is skipped based on the id alone
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
if (activeAccount?.id != activeAccountId) {
Why it matters

If the cached Account object is replaced but its id is unchanged, the stale object stays active.

Suggested fix, not tested

Refresh activeAccount from the cache whenever the cache is rebuilt.

28. Low Spam-hiding condition is copied into each transaction reader
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
if (record.spam && spamManager.hideSuspiciousTx) return@forEach
Why it matters

The condition is repeated in TokenTransactionsService and TransactionFilterService, and a new reader can forget it.

Suggested fix, not tested

Expose one isHidden(record) predicate and call it everywhere.

29. Low Account record columns carry an unchecked invariant
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
val type: String,
val words: SecretList?,
val key: SecretString?,
Why it matters

The type determines which secret column must be set, but nothing enforces or verifies this.

Suggested fix, not tested

Document the invariant and add a startup or post-migration check.

30. Low Spam rows can be stored without an address
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
val spamScore: Int,
val blockchainType: BlockchainType,
val address: String?
Why it matters

Address lookups depend on spam rows having an address, but the write path can store null.

Suggested fix, not tested

Guard the write and add a verification query.

31. Low Spam threshold is duplicated as a SQL literal
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
@Query("SELECT * FROM ScannedTransaction WHERE spamScore >= 7 AND address = :address LIMIT 1")
Why it matters

If the threshold constant changes, the queries and the scorer disagree.

Suggested fix, not tested

Pass the threshold as a query parameter.

32. Low Unused preference accessors remain
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
override var baseBitcoinProvider: String?
    get() = preferences.getString(BASE_BITCOIN_PROVIDER, null)
Why it matters

Several accessors, including an unused authToken slot, have no callers and suggest features that do not exist.

Suggested fix, not tested

Delete them and their keys.

33. Low Bookkeeping after a successful broadcast can report the send as failed
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
val result = sendTransactionService.sendTransaction(swapDefenseState.mevProtectionEnabled)
saveSwapRecord(result)
Why it matters

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.

Suggested fix, not tested

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.

34. Low Contacts file is overwritten in place and write errors are hidden
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
FileOutputStream(file).use { fos ->
    fos.bufferedWriter().use { bw ->
        bw.write(asJsonString)
Why it matters

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.

Suggested fix, not tested

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.

35. Low Contacts map is shared across threads without synchronisation
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
private var contactsMap: MutableMap<String, Contact> = mutableMapOf()
Why it matters

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.

Suggested fix, not tested

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.

36. Low QR images from the gallery are decoded at full resolution
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
val bitmap = context.contentResolver.openInputStream(uri)
    ?.use { BitmapFactory.decodeStream(it) } ?: return null
val intArray = IntArray(bitmap.width * bitmap.height)
Why it matters

A large photo needs hundreds of MB. The resulting OutOfMemoryError is not caught by catch (Exception), so the app crashes or freezes.

Suggested fix, not tested

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.

37. Low Backup and import files are read without a size cap on the main thread
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
val jsonString = br.readText()
//validate json format
BackupFileValidator().validate(jsonString)
Why it matters

A very large file can freeze the UI. The same pattern appears in the backup manager and contacts screens.

Suggested fix, not tested

Cap the size, read and parse on a background dispatcher, and parse once.

38. Low Swap provider file mixes API, mapping and per-chain transaction building
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
class USwapProvider(
Why it matters

At 1,209 lines, the class builds transactions for nine chains in the core module, although chain plugin modules exist.

Suggested fix, not tested

Split out the API and DTOs, and move per-chain transaction construction behind the chain plugin interface.

39. Low Transaction view-item factory holds every chain's converter
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
class TransactionViewItemFactory(
Why it matters

At 1,457 lines, the factory holds the XRP, Stellar, TON, Solana and Tron converters in core, away from their chain modules.

Suggested fix, not tested

Move one converter per chain into each chain module, and keep the factory as a dispatcher.

40. Low View model takes a platform Uri
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
fun handleDeepLink(uri: Uri) {
Why it matters

The view model depends on an Android platform type, which makes it harder to test.

Suggested fix, not tested

Convert the Uri to a String or parsed values in the Activity.

41. Low Send view model does not enforce a single send itself
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
fun onClickSend() {
    viewModelScope.launch(Dispatchers.IO) {
        send()
Why it matters

Only the button composable stops a double send. If a second call reaches the view model, it would broadcast a second transaction.

Suggested fix, not tested

Track the in-flight Job and return early while it is active.

42. Low Cross-chain pay and private send duplicate the send sequence
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
suspend fun send(): SendTransactionResult = withContext(serviceDispatcher) {
Why it matters

The two 60-line blocks are near-identical, so a fix such as finding 33 has to be made twice.

Suggested fix, not tested

Extract one shared send-with-record helper.

43. Low QR-scan and paste actions are copy-pasted across input fields
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
val qrScannerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
Why it matters

The copies have already drifted: only one of them calls onScanQR.

Suggested fix, not tested

Extract one reusable QR-scan button composable.

44. Low Fee and spam thresholds are compiled into the app
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
override val spamCoinValueLimits: Map<String, BigDecimal> = mapOf(
    "XLM" to BigDecimal("0.1"),
Why it matters

Tuning the spam limits or the swap fee requires a new release.

Suggested fix, not tested

Consider serving a versioned rule config from the backend.

46. Low Auto-enable of received tokens is copied per chain, and only TON filters scam tokens
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
if (!tokenAutoEnableManager.autoEnableTokensOnReceive) return

val enabledWallets = newAssets.map { asset ->
Why it matters

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.

Suggested fix, not tested

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.

47. Low The reproducible-build check script no longer matches the build
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
builtApk=$workDir/app/app/build/outputs/apk/release/app-release-unsigned.apk
Why it matters

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.

Suggested fix, not tested

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.

48. Low Release workflows do not run tests or lint
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
run: ./gradlew clean assembleCiRelease --stacktrace --no-daemon
Why it matters

No workflow runs ./gradlew test or lint (searched .github/).

Suggested fix, not tested

Add a test job and make the build and publish jobs depend on it.

49. Low An EVM helper lives in the EVM chain module and forces a chain-to-chain dependency
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
import io.horizontalsystems.walletkit.modules.multiswap.sendtransaction.toEvmTransactionData
Suggested fix, not tested

Move the helper into the shared module and drop the dependency.

50. Low Implicit return@let in deeply nested lambdas
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
value = getFormattedValue(data.points.lastOrNull()?.tvl ?: return@let, currency),
Why it matters

A later edit can silently return from the wrong lambda and drop an analytics block.

Suggested fix, not tested

Use explicit labels, or extract each block into a function.

51. Low Leftover debug logs at error level
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
Log.e("AAA", "billingClient.isReady: ${billingClient.isReady}")
Suggested fix, not tested

Remove them or use debug level with a descriptive tag. The same applies in AdapterFactory.kt and ResultEventBus.kt.

52. Low Failed exchange-rate fetches are retried and logged on every UI call
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
Log.w("XRate", "Could not fetch xrate for ${key.coinUid}:${key.timestamp}, ${e.javaClass.simpleName}:${e.message}")
Suggested fix, not tested

Remember failed keys for a cool-down period and log one summary per sync.

53. Low Chain parity test checks a hand-copied registration
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
// Mirrors the registration in App.onCreate.
ChainRegistry.register(TronChainPlugin())
Why it matters

The test never registers the EVM plugins that the app does, so EVM regressions are not caught.

Suggested fix, not tested

Call the app's real registration function from the test.

54. Low Release APKs are published without checksum files
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
- name: Upload to GitHub Release
Why it matters

The release document requires .sha256 and .md5 assets, but nothing produces them (searched .github/ and docker/).

Suggested fix, not tested

Generate and upload checksums after signing.

55. Low Build script overwrites an existing APK silently
First rating: Low · Reviewed rating: Low · Review: as first rated, not individually re-checked
From the report
Evidence
cp "${WORK_DIR}/${BUILT_APK_FILE}" "${PWD}/unstoppable_wallet_google_play_${TAG}.apk"
Suggested fix, not tested

Check that the target does not exist (cp -n), or abort if it does.

56. Low Third-party API keys and tokens are committed in the build script
First rating: Medium · Reviewed rating: Low · Review: rated too high
Location: app/build.gradle.kts:120 (also lines 47, 72–73, 121–142 and 154–176)
From the report
Evidence
buildConfigFieldString("TWITTER_BEARER_TOKEN", "AAAA…")
buildConfigFieldString("CHAINALYSIS_API_KEY", "928b…")
val uswapApiKeyAndroid = "a32d…"
Why it matters

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.

Suggested fix, not tested

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.

57. Low Auto-lock can be bypassed by setting the device clock back
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
val timeInBackground = System.currentTimeMillis() - lastBackgroundTime
Why it matters

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.

Suggested fix, not tested

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).

58. Low Backup files control the scrypt cost without bounds
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
val kdfParams = crypto.kdfparams
val key = EncryptDecryptManager.getKey(passphrase, kdfParams) ?: throw RestoreException.EncryptionKeyException
Why it matters

A crafted backup with a huge n or r exhausts memory. getKey only catches Exception, so the app crashes during restore.

Suggested fix, not tested

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.

60. Low Subscriptions are wiped before refresh, and a failed purchase launch hangs
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
activeSubscriptions.clear()
if (!billingClient.isReady) {
Why it matters

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.

Suggested fix, not tested

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.

61. Low A malformed WalletConnect Stellar XDR can crash the wallet
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
val transaction = stellarKit.getTransaction(xdr)
Why it matters

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.

Suggested fix, not tested

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.

63. Low Custom Monero nodes are always added as trusted
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
nodeManager.addMoneroNode(url, username, password, true)
Why it matters

The wallet trusts data from any node the user adds, including a hostile one.

Suggested fix, not tested

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.

64. Low Zano custom nodes accept plain http
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
val hasRequiredProtocol = scheme == "https" || scheme == "http"
Why it matters

Node traffic, including alias answers, can be read or changed on the network. Monero and Zcash already require https or onion.

Suggested fix, not tested

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.

65. Low Zano alias resolution trusts the currently selected node
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
val baseUrl = zanoNodeManager.currentNode.host.trimEnd('/')
Why it matters

A hostile or intercepted node can return any address for an alias, and that address goes straight into the send flow.

Suggested fix, not tested

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.

66. Low personal_sign signs different bytes than the dApp sent
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
String(message.hexStringToByteArray())
Why it matters

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.

Suggested fix, not tested

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.

67. Low Accounts that fail to decode silently disappear
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
} catch (ex: Exception) {
    null
}
Why it matters

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.

Suggested fix, not tested

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.

68. Low Wallet reload deletes and then re-adds wallets in separate writes
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
delete(walletsToReAdd)
save(walletsToReAdd)
Why it matters

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.

Suggested fix, not tested

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.

69. Low Loading contacts drops unknown addresses, and the next save makes the loss permanent
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
addresses = contactJson.addresses.mapNotNull { addressJson ->
    marketKit.blockchain(addressJson.blockchain_uid)?.let { ContactAddress(it, addressJson.address) }
Why it matters

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.

Suggested fix, not tested

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)

45. Info User-visible text can only be changed by a release
First rating: Info · Reviewed rating: Info · Review: location checked, kept as rated
From the report
Evidence
object Translator {

Note: Strings resolve only from bundled resources. No override mechanism was found. If this is intentional, record the decision.

Want this for your code?

Upload a ZIP or link a public GitHub repo, pick the areas and the depth, and get findings by severity with fixes.