| Audited | solana-mobile/mobile-wallet-adapter at commit 100e023048b7ffd3a67af2bf334f4bda3b9d8d7c solana-mobile/seed-vault-sdk at commit 8f8ba289fed8c53f2deb3194fbbd73f4ab6fe028 |
|---|---|
| Date | 11 October 2026 |
| How it ran | local run on our workstation, full audit, Standard review |
| Verdict after review | Fail (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. 90 findings were first rated Medium or High; 9 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.
High after review (1)
From the report
Evidence: .map((output) => { return base64FromUint8Array(output.signature); });
Solana transaction signatures are base58. The returned value is not a valid signature string, so confirmTransaction and explorer lookups fail for every transaction sent through this path. The package's own test expects base58. Apps that retry after a failed confirmation can send a payment twice.
Encode with base58FromUint8Array.
Review: The sign-and-send branch returns base64FromUint8Array(output.signature). The local wallet always exposes sign-and-send, so wallet-adapter-mobile's sendTransaction takes this branch and returns a base64 string where a base58 transaction signature is required; confirming it fails. The sibling web3.js client correctly maps to base58, so this is a bug, not a design choice. One claim in the report is wrong: the package's own test compares against a base64 encoder, so it locks the bug in. The duplicate-payment consequence is speculative: the transaction is sent once, only the returned id is wrong. Kept at HIGH because the default send path of a published npm package returns an unusable signature for every user. Upstream: PR #1662 "fix: return base58 signatures from sendTransaction" was closed unmerged on 2026-10-07.
Upstream: No exact upstream report; related items: #1662.
Medium after review (8)
From the report
Evidence: ProtocolContract.ERROR_AUTHORIZATION_FAILED -> "Auth token invalid"
Every later call fails with the same dead token until the app disconnects explicitly.
Clear the token on this error, or retry once with a fresh authorize.
Review: On ERROR_AUTHORIZATION_FAILED the client builds a Failure message ("Auth token invalid") and never clears authToken. The next transact sends the same token again. Ordinary token expiry or revocation on the wallet side therefore makes every later connect fail the same way until the app clears the token or calls disconnect. The failure is loud, which is why this is borderline. It is in the shipped clientlib-ktx and the fix is one line.
From the report
Evidence: if (this.#session) { return callback(this.#session.wallet); }
Later calls reuse a dead session, and connected stays true.
Clear the session on close or error, emit a change event, and retry once.
Review: #transact reuses #session whenever it is set; it is cleared only on explicit disconnect or a fresh authorization. A request made after the remote socket closed is sent on a closed socket and its promise never settles; no modal is shown, so the user has no cancel path, and connected stays true. The trigger is a remote wallet's phone sleeping or backgrounding. Remote transport only.
mobile-wallet-adapter/js/packages/wallet-adapter-mobile/src/__forks__/react-native/createDefaultAuthorizationResultCache.ts:28 (line corrected on review; the report cites :27)From the report
Evidence: (JSON.parse((await asyncStorage.getItem(CACHE_KEY)) as string) as AuthorizationResult)
Uint8Array keys come back as plain objects, unlike in the web caches.
Rebuild the byte arrays on read, as the other caches do.
Review: The cached accounts carry publicKey: Uint8Array, which JSON.stringify turns into a plain object. The web cache rebuilds the byte array on read; the React Native fork returns the parsed object as is, and its test uses accounts without a publicKey. On a silent reconnect, adapter.publicKey is built from a plain object, which should fail. The exact failure mode was inferred from the library constructor rather than executed.
From the report
Evidence: mIoHandler.post(() -> mCallbacks.onAuthorizeRequest(new AuthorizeRequest(future, ..., chain, ...)));
The chain value comes straight from the dApp's authorize request. The AuthorizeRequest constructor throws IllegalArgumentException for any value that is not namespace:reference (AuthorizeRequest.java:63). The throw happens inside a runnable on the wallet's I/O looper thread, so it is uncaught and the wallet process crashes. Any connecting dApp can crash the wallet with one request.
Validate chain with Identifier.isValidIdentifier in handleAuthorize, and answer with an invalid-params or cluster-not-supported error. Never throw inside posted runnables.
Review: A chain value such as "foo" passes the server's checks untouched; the AuthorizeRequest constructor then throws inside a runnable posted to the wallet's I/O looper, which has no handler, so the wallet process dies. Confirmed, but the impact is availability only: one dApp request after a session is up, no state corruption, no signing impact. The fix is a one-line validation.
From the report
Evidence: } else { throw e } … } ?: throw NullPointerException("Tried to invoke $method without an active session")
A transport or protocol failure that is neither a remote JSON-RPC error nor a timeout is rethrown from the @ReactMethod. The sibling catch (Throwable) does not catch a throw from inside another catch block, so the host app process crashes. Calling invoke or endSession (line 223) without a session crashes the app the same way.
Reject the promise on every path, and never throw from a bridge method.
Review: The rethrow inside a catch block is real and escapes the bridge method, which crashes the host app. The reachable cause is narrow: it needs a wallet response with neither result nor error, or a malformed error. The no-session paths are not reachable through the shipped JavaScript. Fair MEDIUM: shipped package, but the trigger needs a misbehaving wallet.
From the report
Evidence: public void onSessionError() { ... if (mClientCount.decrementAndGet() == 0) {
Close and error callbacks also fire for sessions that never finished establishing, and so were never counted. Any local process can connect and drop, pushing the count to -1. A real session then never starts the auth repository, or an active one is shut down mid-session.
Decrement only for sessions that were counted.
Review: Close and error callbacks decrement the counter even for connections that never completed the handshake. A local process that connects and drops can take a live session's count to zero, which clears the active authorization and stops serving, or push it negative so the next real session is never announced. Impact: a local denial of service of a signing session.
From the report
Evidence: for (int c = 0; c < chainsArr.length(); c++) { chains[c] = chainsArr.getString(0); }
dApps receive wrong per-account chain and feature lists. Line 321 has the same bug, and line 316 also reads the wrong key.
Use getString(c), and read the correct key.
Review: chains[c] = chainsArr.getString(0) (and the same for features) gives silently wrong data in the public AuthorizedAccount.chains and .features of the shipped clientlib. Trivial fix; few dApps read these fields yet.
From the report
Evidence: addListener(SEED_VAULT_EVENT_BRIDGE_NAME, ...) while native code emits on "SeedVaultContentChangeEventBridge"
handleContentChange is never called.
Subscribe to both channels, or emit on one shared constant.
Review: The native module emits content changes on one event channel and the useSeedVault hook listens on another, with no other subscriber in the repository, so the documented content-change callback can never fire.
Low after review (137)
From the report
Evidence: bytes[i] = (c <= BASE58_ALPHABET_ASCII_LOOKUP.length ? BASE58_ALPHABET_ASCII_LOOKUP[c] : -1);
The lookup table has 128 entries, so character U+0080 throws ArrayIndexOutOfBoundsException instead of the documented IllegalArgumentException. Callers that parse dApp-supplied sign-in addresses catch only the documented type.
Use c < BASE58_ALPHABET_ASCII_LOOKUP.length, or replace the hand-written codec with a maintained Base58 library.
From the report
Evidence: handleActivityResult(requestCode, resultCode, data) (module-wide listener) and if (receivedRequestCode == requestCode) { ... callback(resultCode, data) (per-call listener)
Each vault result goes to both the event API and the promise API. Request codes are small integers (0–6) that can collide with other modules. JavaScript code can act on one signing result twice.
Route each request to exactly one consumer, keyed by a unique high request code. Remove the per-call listener once it fires. Reject the promise when no activity is available.
From the report
Evidence: type AuthToken = number; while native code does putString("authToken", "$authToken")
The documented types do not match what crosses the bridge. Passing a number where a native String parameter is expected fails at runtime. 64-bit values can also lose precision as JavaScript numbers.
Type tokens and IDs as decimal strings end to end. Add one native parse helper that validates the value and rejects instead of throwing NumberFormatException.
From the report
Evidence: else -> map.putString(key, value.toString())
A null field in a wallet response (for example wallet_uri_base) arrives as the text "null", so it looks present and then fails validation. Long values arrive as strings, and nulls are silently dropped from arrays, which shifts indexes.
Map JSONObject.NULL to putNull and pushNull, and handle Long explicitly.
From the report
Evidence: socket.addEventListener('close', () => { rejectPendingRequests(
If the reflector connection closes before the session is established, the wallet promise is never rejected. The dApp and its modal wait forever on "waiting for scan".
Reject the wallet promise on close or error while the session is not yet established. Remove the stale first-phase error handler that can re-dial.
mobile-wallet-adapter/js/packages/wallet-adapter-mobile/src/adapter.ts:204 (line corrected on review; the report cites :203)From the report
Evidence: async connect(): Promise<void> { this.#connect(); }
Callers see success immediately. Connection failures and cancellations become unhandled promise rejections instead of reaching the app.
return await this.#connect() in both connect() and autoConnect() (line 199).
From the report
Evidence: Log.d(TAG, "invoke $method with params $params")
params holds auth tokens, transactions and sign-in payloads. They end up in logcat, bug reports and crash tooling for every app that embeds the library. Lines 82 and 195 log the same kind of data.
Log only the method name and a request ID.
From the report
Evidence: Log.d(TAG, "Seed authorized, AuthToken=$authToken")
The auth token is the credential for signing and key derivation on a seed. It is logged on every authorize, create, import, deauthorize and event path (also lines 200, 228, 245, 575, 584, 593). Signatures and public keys are logged too.
Remove the values from the log messages.
From the report
Evidence: Log.v(TAG, "Returning AuthRecord from auth token: " + authRecord);
The record's text form includes the dApp identity and the authorized public keys. Every issue, reissue, revoke and lookup therefore logs which address is linked to which app (also lines 270, 433, 443, 456).
Log record IDs only.
From the report
Evidence: jo.optInt(ProtocolContract.RESULT_MAX_TRANSACTIONS_PER_REQUEST, 0)
An absent limit cannot be told apart from an explicit 0, which means "no limit".
Use has() with a nullable value.
From the report
Evidence: val scenario = scenarioProvider.provideAssociationScenario(timeout)
Keys and a scenario are created before the code learns that no wallet is installed.
Call LocalAssociationIntentCreator.isWalletEndpointAvailable first and return "no wallet found".
From the report
Evidence: TransactionResult.Failure(e.message.toString(), e)
Users can see internal text or the literal word null. The wallet server also echoes internal exception messages to dApps.
Return a fixed, readable message and keep the exception for logs.
mobile-wallet-adapter/js/packages/wallet-standard-mobile/src/embedded-modal/errorModal.ts:48From the report
Evidence: errorMessageElement.innerHTML = An unexpected error occurred: ${error.message};
The current callers pass fixed errors, but this is a latent HTML-injection sink inside the dApp's page.
Use textContent.
From the report
Evidence: Association port number must be between 49152 and 65535.
The message describes a different constraint, which misleads anyone debugging.
State the real reflector-ID range.
From the report
Evidence: } catch (e) { console.error(e);
The wallet UI never learns that session start failed.
Surface the failure through a callback or event.
From the report
Evidence: private var callback: ((Int) -> Unit)? = null
If the app is killed while the wallet is in front, the outcome of a sign-and-send request is unknown. The Seed Vault React Native module has the same gap: its KEY_PENDING_EVENT constant is declared but never used.
Persist a minimal phase record and reconcile it on resume.
From the report
Evidence: Log.d(TAG, "WALLET SEND MESSAGE: " + new String(message));
Protocol traffic is exposed to anyone who can read the device log.
Remove the line, or gate it behind a debug flag.
From the report
Evidence: val result = viewModel.processLaunch(intent, callingPackage)
A rotation starts a second scenario on the same port. The singleTask activity has no onNewIntent, so a second association intent is ignored.
Process the launch only when savedInstanceState == null, and handle onNewIntent.
From the report
Evidence: if (heldPermission.equals(ri.activityInfo.permission)) { intent.setClassName(...)
A look-alike app that declares the same filter and permission string could receive the user's recovery-phrase entry.
Verify the resolved package's signature before launching, as isAvailable already does.
From the report
Evidence: const timeoutId = setTimeout(() => { cleanup(); reject();
A slow wallet cold start is reported as "wallet not found", and users who have a wallet are shown the install prompt.
Lengthen or remove the heuristic.
From the report
Evidence: await asyncStorage.setItem(CACHE_KEY, JSON.stringify(authorizationResult));
The auth token is a credential. The Kotlin sample's PersistanceUseCase.kt:56 stores it in plain SharedPreferences with backup enabled.
Use Keystore-backed encrypted storage with a pinned library version.
From the report
Evidence: try { return SolanaMobileWalletAdapter.invoke(method, params); } catch (e) {
Without await, rejections bypass handleError. endSession in finally can also mask the original error.
Use return await, and wrap endSession in its own try/catch.
From the report
Evidence: Promise.race([ clientTrustUseCase!!.verifyReauthorizationSource(...), async () => { setTimeout(() => { throw new Error(
The race is given a function rather than a promise, so it wins immediately and the request is never resolved.
Race against a real rejecting timeout promise.
mobile-wallet-adapter/examples/example-react-native-wallet/utils/ClientTrustUseCase.tsx:26From the report
Evidence: return new VerificationSucceeded(AssociationType.LocalFromBrowser, ...)
Any web page can claim any identity and be treated as verified. android/fakewallet/.../ClientTrustUseCase.kt:53 does the same.
Return "not verifiable" until real verification exists.
From the report
Evidence: putString(WALLET_URI_BASE, walletUriBase.toString())
The stored string is read back as a bogus relative URI.
Store the value only when it is non-null, and remove the key otherwise.
From the report
Evidence: release { ... signingConfig signingConfigs.debug
Developers copy this configuration into production apps.
Supply release signing from outside the repository.
From the report
Evidence: } catch {}
Users get no feedback when connecting or signing fails.
Show a readable message and log the details.
seed-vault-sdk/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/ui/seeddetail/SeedDetailActivity.kt:124 (line corrected on review; the report cites :125)From the report
Evidence: if (intent.action == WalletContractV1.ACTION_CREATE_SEED) { viewModel.createNewSeed(authorize) }
This runs on every onCreate. A rotation or theme change silently generates a new phrase after the user has written down the old one, or wipes the words typed so far.
Initialise only when savedInstanceState == null, or when the view model is not yet initialised.
From the report
Evidence: authorizeCommonViewModel.setRequest(callingActivity, uid, intent)
The request is resubmitted on every recreation, which resets the PIN and biometric failure counters and discards the user's seed selection.
Set the request once, and keep the counters across recreation.
From the report
Evidence: classpath 'com.android.tools.build:gradle:9.2.1'
The default Kotlin version is 1.9.0, namespace is missing, and the build still uses the lintOptions DSL. These do not line up with the pinned plugin version.
Use one compatible AGP/Gradle/Kotlin set, add namespace, and replace lintOptions with lint {}.
From the report
Evidence: classpath 'com.android.tools.build:gradle:8.2.2'
A host app that uses both packages resolves a single plugin version. The resulting tool errors look like project errors.
Align the packages, or stop pinning the plugin in library build scripts.
From the report
Evidence: classpath 'com.android.tools.build:gradle:7.4.2' … implementation "com.facebook.react:react-native:+"
The module is out of step with the repository root, and + makes builds non-reproducible.
Inherit the plugin version from the host app, and pin the React Native dependency.
From the report
Evidence: private var requestCode: Int? = null
A result that arrives after recreation matches nothing and is dropped.
Save and restore the request code.
seed-vault-sdk/fakewallet/src/main/java/com/solanamobile/fakewallet/ui/MainActivity.kt:216From the report
Evidence: check(pendingEvent == null) { "Received a request while another is pending" }
A double tap crashes the activity.
Ignore or queue the second request, or disable the button while one is pending.
From the report
Evidence: when (grantResults[0]) {
An interrupted permission dialog delivers an empty array, which throws here.
Use grantResults.firstOrNull(), and request the permission only on first creation.
From the report
Evidence: override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
A timeout opens system Settings without the user asking.
Act only on DISMISS_EVENT_ACTION.
From the report
Evidence: <androidx.constraintlayout.widget.ConstraintLayout ... android:layout_height="match_parent">
Lower controls are unreachable on small screens and in landscape. The same applies to the fakewallet authorize and sign fragments.
Wrap the content in a ScrollView.
From the report
Evidence: android:layout_marginTop="400dp"
On shorter screens the title and camera preview are pushed off-screen.
Use guideline- or percentage-based constraints.
From the report
Evidence: val currScale = LocalDensity.current.fontScale.times(displayScaleFactor())
Users with large-text settings get fixed-size headings while body text grows.
Do not invert the user's font scale, or limit the compensation to a documented device class.
From the report
Evidence: tint = Color.Black,
The icon does not follow the theme and disappears on dark backgrounds.
Use the theme's content colour.
From the report
Evidence: .background(Color.White)
The literal colour bypasses the theme. SelectSeedContents.kt:120 repeats it.
Add a named theme token and use it in both places.
From the report
Evidence: items(count = seedDetails.seeds.size) { index ->
After a delete or reload, scroll position and item state follow the index instead of the seed.
Use items(seeds, key = { it.id }).
From the report
Evidence: ModalBottomSheet( sheetState = sheetState,
The dimming doubles when moving between sheets.
Use a transparent scrim here too, as the authorize sheet does.
From the report
Evidence: <item name="android:windowBackground">@android:color/transparent</item>
The first frame does not match the Compose theme.
Set the window background from the theme's background colour, with a night variant.
From the report
Evidence: <item name="colorPrimary">@color/purple_500</item>
The XML theme and the Compose theme define different palettes.
Derive the XML theme from the Compose tokens, or strip it to a bare parent.
From the report
Evidence: titleSmall = TextStyle( ... fontSize = 18.sp, (titleMedium is 16sp)
Visual hierarchy is reversed.
Make the title sizes decrease from large to small.
From the report
Evidence: return if (isSystemInDarkTheme()) Color(0xFF2F2A35) else Color(0xFFE8E0EB)
The shimmer ignores the theme's dark-mode override.
Define named tokens in both palettes.
From the report
Evidence: .height(197.dp) .width(178.dp)
Text clips under larger font or display sizes.
Size the dialog from its content.
From the report
Evidence: Row(... .padding(horizontal = Sizes.dp32), horizontalArrangement = Arrangement.SpaceBetween
The labels do not sit over their columns.
Render the headers as the grid's first row.
From the report
Evidence: height: '90%',
The parent has no defined height, so the layout is unpredictable, and safe areas are ignored.
Use flex: 1 and safe-area insets.
From the report
Evidence: if (mActiveAuthorization == authRecord) {
The freshly loaded record is a new object, so the reference comparison never matches. The live session keeps signing rights after its token is revoked.
Compare by record ID, or re-check revocation in the signing guards.
From the report
Evidence: mAccountsDao.insert(authRecordId, account.publicKey, account.accountLabel, account.accountIcon, account.chains, account.features);
Display address and format are accepted at issue time but never persisted, so reauthorization returns different accounts.
Add the columns with a migration and carry them through the record.
From the report
Evidence: if (aa.accountLabel != null) { account.put(ProtocolContract.RESULT_ACCOUNTS_LABEL, aa.accountLabel); }
The wallet supplies per-account chains and features, but dApps never receive them.
Serialise both fields when they are present.
From the report
Evidence: final LooperThread t = new LooperThread(); t.start(); (quit only in finalize(), line 124)
Threads accumulate until a garbage collection happens to run the finaliser.
Quit the looper in close() and remove finalize().
From the report
Evidence: authToken = it.authToken
Overlapping calls or a cluster change can overwrite a fresh token with a stale one.
Guard the auth state with a Mutex and store the chain with the token.
From the report
Evidence: const cachedAuthorizationResult = await this.#authorizationCache.get(); if (cachedAuthorizationResult) {
A cluster switch keeps targeting the old chain. A sign-in request is answered from the cache with no fresh nonce-bound signature.
Accept a cache hit only when the chain and identity match and no sign-in payload is present.
From the report
Evidence: this.#authorizationCache.clear(); // TODO ... not awaiting
A late write after disconnect brings the token back into storage, and the next page load reconnects silently.
Await the cache operations, or serialise them through one queue.
From the report
Evidence: #disconnect = async () => { this.#authorizationCache.clear();
The wallet-side token stays valid while the dApp believes it has disconnected.
Call deauthorize, on a best-effort basis, before clearing local state.
From the report
Evidence: const result = await callback(await wallet); loadingSpinner.close(); close();
When the callback throws, the session and socket stay open.
Close in a finally block.
From the report
Evidence: if (properties.accounts && properties.accounts.length > 0) {
The selected account and public key stay set after a wallet-side disconnect.
On an empty list, clear the state and emit disconnect.
From the report
Evidence: seedCollection.first() (a state flow seeded with SeedCollection.getDefaultInstance())
The "wait until valid" step returns immediately with the empty default. Queries see no data, and the ID counters can reuse IDs.
Wait for the first real emission from the store.
From the report
Evidence: if (authRecord == null || authRecord.isRevoked()) {
Expiry is enforced at token lookup, but the guard on an already-active session checks revocation only.
Use one shared "is usable" predicate everywhere.
From the report
Evidence: COLUMN_ACCOUNTS_PARENT_ID + " INTEGER NOT NULL,"
Orphan accounts and authorizations without accounts are possible, and the code reads accounts[0].
Add foreign keys with cascade, and enable them in onConfigure.
From the report
Evidence: Executors.newSingleThreadScheduledExecutor();
Each scenario leaves a worker thread behind.
Call shutdownNow() on close.
From the report
Evidence: this.#authorization.accounts.some((account, ii) => account.address !== authorization.accounts[ii].address);
Label, icon, chain and feature updates are stored but never emitted.
Compare whole account objects.
mobile-wallet-adapter/js/packages/wallet-standard-mobile/src/createDefaultAuthorizationCache.ts:5From the report
Evidence: const CACHE_KEY = 'SolanaMobileWalletAdapterDefaultAuthorizationCache';
Two instances overwrite or clear each other's data.
Prefix the key with its owner, and store a kind and version.
mobile-wallet-adapter/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/authorization/AccountRecordsDao.java:85 (line corrected on review; the report cites :84)From the report
Evidence: COLUMN_ACCOUNTS_PARENT_ID + "=?" + parentId
The placeholder is never bound, so the query can never match. The method is unused.
Delete it, or bind the argument.
From the report
Evidence: return content.split(ARRAY_DIVIDER);
Empty lists round-trip as one empty string, and values containing the divider split wrongly.
Store the lists as JSON.
From the report
Evidence: for (int i = start; i < bytes.length; i++) { mod = mod * 58 + bytes[i];
Decoding is quadratic, and the sign-in address passed to it is chosen by the dApp. A long string can tie up the wallet's protocol thread.
Reject inputs longer than an encoded address or signature (44 or 88 characters) before decoding.
From the report
Evidence: new Draft_6455(Collections.emptyList(), Collections.singletonList(new Protocol(...)))
Any local process can send huge frames before any association check and exhaust wallet memory.
Configure a maximum frame and message size.
From the report
Evidence: o = new JSONObject(jsonStr); } catch (JSONException e) {
Deep nesting causes a StackOverflowError, which bypasses the handler. The client parser (JsonRpc20Client.java:160) is the same.
Cap the size and nesting depth before parsing.
From the report
Evidence: if (!NostrCrypto.verifyEvent(event)) { ... if (!senderPubkey.equals(mDappNostrPubkey)) {
Anyone publishing events with the session tag can force expensive signature work. The JavaScript client (transact.ts:924) has the same order.
Compare the sender key first, cap the message length, then verify.
From the report
Evidence: dispatchRpc(id, method, params);
A dApp can flood the wallet with prompts. A late authorize can overwrite another request's active authorization.
Track the outstanding request, and reject or queue additional ones.
From the report
Evidence: protected AuthRecord mActiveAuthorization = null;
An authorization granted on one connection is visible to signing requests on another.
Scope the active authorization to each session, and limit the number of live sessions.
From the report
Evidence: this.#session = { close, wallet: await wallet };
Concurrent calls open several sessions, and earlier ones are dropped without being closed.
Reuse the in-flight session promise, and close sessions before discarding them.
From the report
Evidence: #performSignIn = async (input?: SolanaSignInInput) => { this.#connecting = true;
Overlapping flows clear each other's flag, and a double tap starts two associations.
Keep one in-flight promise per operation.
From the report
Evidence: if (uri == scenarioUri && scenario != null) {
Concurrent intents create two scenarios. The replaced one is never closed and keeps its port.
Guard creation with a mutex, and close the previous scenario.
From the report
Evidence: if (mutex.isLocked) { mutex.unlock() }
A late cleanup can release the lock held by the next session.
Use withLock, or lock and unlock with an owner token.
From the report
Evidence: final int authRecordId = (int) mAuthorizationsDao.insert(identityRecord.getId(), now, cluster, walletUriBaseId, scope);
A failed insert (-1) still produces a token that can never be redeemed. A crash midway leaves an authorization with no accounts.
Wrap issue and reissue in a transaction, and fail on -1.
From the report
Evidence: throw new IllegalArgumentException("Unknown/unsupported version: " + versionString);
A wallet that speaks a newer version breaks session establishment instead of negotiating down.
Fall back to the highest mutually supported version.
From the report
Evidence: value.booleanOrNull != null -> map.putBoolean(key, value.boolean) … array.pushString(value.toString()) (line 91)
Strings such as "123" or "true" become numbers or booleans. Array strings keep their JSON quotes, so feature IDs never match. Seed Vault's Extensions.kt has the same code.
Check isString first, and use value.content.
From the report
Evidence: "(?:(?:[^:?#]+):)?(?:[^?#\\n]*)?(?:[^?#\\n]*)..."
Adjacent unbounded quantifiers and a repeated group make this public parser slow on long input.
Cap the input length, or parse line by line.
From the report
Evidence: Pattern.compile("^\\S+:\\S+$")
Chain strings from dApps reach this pattern unbounded.
Check the length first, then scan for the colon.
seed-vault-sdk/seedvault/src/main/java/com/solanamobile/seedvault/Bip32DerivationPath.java:149From the report
Evidence: for (int i = 1; i < path.size(); i++) {
The loop walks every segment before the depth limit applies, and the overflow uses an undocumented exception type.
Check the size first, and throw the documented exception.
From the report
Evidence: export async function startNostrScenario(config: NostrWalletAssociationConfig)
The local, remote and Nostr copies have already drifted; only the local path handles ping and close properly.
Extract the shared handshake and request table.
From the report
Evidence: public class MobileWalletAdapterServer extends JsonRpc20Server { (about 1,100 lines)
The size and mixing make validation gaps like those in this report easy to miss.
Split out the request and result types, and centralise parameter validation.
From the report
Evidence: private final AtomicInteger mClientCount = new AtomicInteger();
The three copies already behave differently.
Move the counting into the base class once.
From the report
Evidence: export class RemoteSolanaMobileWalletAdapterWallet
The copies already differ in how they detect capabilities.
Extract a shared base or helper.
From the report
Evidence: run: ./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository
The release reaches Maven Central inside the build job. The integration test job depends on that job, so it runs too late to block the release.
Publish from a job that needs both build and test.
From the report
Evidence: response.accounts.first().let { account ->
A multi-account authorization silently shrinks to one account, unlike in the native library. An empty list throws inside an unguarded coroutine and crashes the wallet.
Map every account and call the array overload. Reject an empty list.
From the report
Evidence: publish: pnpm publish-packages
Publishing builds, then publishes. Tests and type checks run in another workflow that does not gate it. The Seed Vault workflow has the same shape.
Run tests and type checks before publishing.
From the report
Evidence: gh release download -R plantuml/plantuml -p 'plantuml-[0-9]*[0-9].jar'
The latest release runs unverified.
Pin a version and verify its SHA-256.
From the report
Evidence: internal fun ReadableMap.toJson(): JsonObject = buildJsonObject {
The copies are identical, so the string-handling defect in finding 100 exists in both.
Extract one shared helper library.
From the report
Evidence: object Base58EncodeUseCase { ... private val BASE58_ALPHABET = byteArrayOf(
The common module already ships Base58; the samples carry their own copies.
Reuse the shared codec.
From the report
Evidence: export function augmentWalletAPI(wallet: MobileWallet): KitMobileWallet { return new Proxy<KitMobileWallet>(
A fix applied to one client can be missed in the other.
Share the wrapper, and keep only the transaction codecs separate.
mobile-wallet-adapter/js/packages/wallet-standard-mobile/src/embedded-modal/loadingSpinner.ts:94From the report
Evidence: export default class EmbeddedLoadingSpinner { #root ...
The root lookup, listeners, open/close and injection logic are copied.
Extend the shared modal base.
From the report
Evidence: return btoa(String.fromCharCode.call(null, ...byteArray));
The package already exports this helper, and the spread throws on large inputs.
Import the shared encoder.
From the report
Evidence: return@with it.with(SignInResult(
A later edit can silently change which block the return exits.
Use explicit labels, or a named function.
From the report
Evidence: signInPayload = signInJson != null ? SignInWithSolana.Payload.fromJson(signInJson) : null; } catch (JSONException e) {
A bad nonce, date or address in the sign-in payload, or an unknown cluster (line 171), throws an unchecked exception. The connection drops with no reply. The error message on line 200 also names the wrong parameter.
Also catch IllegalArgumentException, and return invalid-params with a correct message.
From the report
Evidence: throw new IllegalArgumentException("request must contain an array of addresses with which to sign messages");
The dispatcher catches only IOException. A malformed request kills the connection, and the same applies at lines 773 and 780.
Respond with an invalid-params error and return.
From the report
Evidence: if (o.keys().hasNext()) { handleRpcError(id, ERROR_INVALID_PARAMS, "params expected to be empty", null); }
Execution falls through, so the client receives an error and a result for the same request ID.
Add return; after the error response.
From the report
Evidence: final byte[] payload = Base64.decode(authToken, Base64.DEFAULT);
The dApp chooses the token. Invalid base64 aborts the request instead of producing "authorization failed".
Catch the decode error and return null.
From the report
Evidence: new ReauthorizeRequest(future, request.identityName, request.identityUri, request.iconUri, authRecord.chain, authRecord.scope)
Auth tokens are bearer tokens. An app holding another app's token can present its own identity, and wallet-side source checks see that identity instead of the one stored with the token.
Pass the stored identity, and reject a mismatch.
From the report
Evidence: final int seqNum = ByteBuffer.wrap(payload, 0, SEQ_NUM_LENGTH_BYTES).getInt();
Frames shorter than the header, IV and tag throw exceptions the receive handler does not catch. The sequence number also advances before authentication succeeds.
Check the minimum length first, and commit the sequence number only after the tag verifies.
From the report
Evidence: otherPublicKey = ECDSAKeys.decodeP256PublicKey(message); } catch (UnsupportedOperationException e) {
The decoder throws IllegalArgumentException for short or wrongly prefixed keys (ECDSAKeys.java:46). Any process answering on the loopback port can crash the dApp's session thread.
Catch both exception types, and validate that the point is on the curve.
From the report
Evidence: return schnorrVerify(hexToBytes(id), hexToBytes(sig), hexToBytes(pubkey)); } catch (JSONException e) {
Odd-length hex from an untrusted relay throws StringIndexOutOfBoundsException, which tears down the session.
Validate length and alphabet before decoding, and return false on any failure.
From the report
Evidence: publicKey = JsonPack.unpackBase64PayloadToByteArray(b64EncodedAddress); … Uri.parse(walletIconStr)
Invalid base64 throws, an empty accounts array is indexed at 0, and the wallet icon accepts any URI scheme.
Map decode errors to an invalid-response error, require at least one account, and allow only data: icons.
From the report
Evidence: b = bytes[offset++]; value |= (b & 0x7f) << (7 * offset);
The shift uses the already-incremented offset, so a one-byte length is multiplied by 128. The code works only because slice clamps to the buffer end.
Shift by 7 * (offset - 1), cap the byte count, and bounds-check the length.
From the report
Evidence: const walletNostrPubkey = event.pubkey;
The session tag is derived from the public association key. Anyone who sees it can race the real wallet, become the pinned peer and receive the handshake.
Filter the subscription on events addressed to the dApp key, and verify the address before pinning.
From the report
Evidence: const sharedSecret = await parseHelloRsp(responseBuffer, state.associationPublicKey,
Parse failures become unhandled rejections and the wallet promise never settles. A clean close before the handshake also never rejects.
Catch in the handshake branch, reject, and close the socket.
From the report
Evidence: const result = await Promise.race([ (async () => { ... callback(await wallet)
The app reports a timeout while the wallet later connects and prompts the user to sign. Retrying risks a duplicate transaction.
Close the scenario on timeout, and check a cancelled flag before running the callback.
From the report
Evidence: val kotlinConfig = json.decodeFromString(MobileWalletAdapterConfigSerializer, config)
This runs inside launch on a scope with no exception handler. A decode or start failure kills the process and never settles the promise.
Catch and reject, and add a CoroutineExceptionHandler.
From the report
Evidence: verifier.verify(packageName, URI.create(clientIdentityUri)) … packageManager.getPackageUid(packageName, 0) (line 55)
The dApp chooses these values. A bad URI or unknown package crashes the wallet during an unauthenticated authorize request.
Catch the exceptions, and return false or reject.
From the report
Evidence: private val pendingRequests = mutableMapOf<String, MobileWalletAdapterRemoteRequest>()
Several threads mutate the map. cancelRequest (line 204) casts entries to a type they never have, so cancellation is a no-op.
Use a ConcurrentHashMap, cancel through .request, and remove resolved entries.
From the report
Evidence: .methodCall(method, convertMapToJson(params), CLIENT_TIMEOUT_MS).get() as JSONObject
The call can block the shared native-modules thread for up to 90 seconds.
Run the call in the module's I/O scope and settle the promise from there.
From the report
Evidence: init { reactContext.addActivityEventListener(mActivityEventListener) }
Listeners, scopes and sockets survive a React reload. The wallet-side module (SolanaMobileWalletAdapterWalletLibModule.kt:185) leaks its running scenario the same way.
Implement invalidate() to remove listeners, cancel scopes and close scenarios.
From the report
Evidence: throw AssertionError("Accounts are not expected to be deleted")
The repository does emit account-delete notifications (SeedRepository.kt:465), so "remove all accounts" crashes the vault process.
Handle the delete notification, and never throw inside the collector.
From the report
Evidence: val didInitialization = synchronized(this::seedRepository) {
A property reference is a new object on each evaluation, so binder threads do not exclude each other and initialisation can run twice.
Lock on a private lock object, or use by lazy.
From the report
Evidence: // TODO: validate message is a Solana-compatible message before signing
A requester can get a transaction signed while the user sees a harmless message prompt.
Reject message payloads that parse as transactions.
From the report
Evidence: throw new UnsupportedOperationException("requestPublicKeys did not return a result");
The method's contract says ActionFailedException, so callers that follow the documented pattern crash.
Throw ActionFailedException, and close cursors on the failure paths.
From the report
Evidence: val application = reactContext.currentActivity?.application!!
When there is no current activity, or the provider returns null, this throws a null-pointer exception and the app crashes. Cursors are never closed.
Use the application context, reject on null, and close cursors with use {}.
From the report
Evidence: Wallet.getAccounts(application, authToken.toLong(), ...)
Number parsing, base64 and JSON decoding, and vault API errors are uncaught in bridge methods and result callbacks (also line 498).
Wrap each method, reject the promise, and skip invalid entries.
From the report
Evidence: checkIsSeedVaultAvailable(true); checkSeedVaultPermission();
Bridge calls repeat on every render, their rejections are unhandled, and production wallets accept the insecure simulator.
Run the checks in useEffect with error handling, and default allowSimulated to false.
From the report
Evidence: reactContext.contentResolver.registerContentObserver(WalletContractV1.WALLET_PROVIDER_CONTENT_URI_BASE, true, object : ContentObserver(
After a reload the observer fires into a destroyed context and throws on the main looper.
Unregister it in invalidate().
From the report
Evidence: if (resultCode != Activity.RESULT_CANCELED) { ... callback(null)
The JavaScript caller waits forever when the user cancels.
Settle the promise on cancel.
From the report
Evidence: public AuthRecord issue(@NonNull String name, ... (not synchronized)
Every other public method takes the lock. Here, multi-step inserts and purges race with revoke and reissue.
Make both issue overloads synchronized.
From the report
Evidence: throw new RuntimeException("Android keystore error; aborting", e);
Only a missing key is recovered. A corrupt key crashes the wallet on its I/O thread.
Recreate the key and reset the database, or surface a recoverable error.
From the report
Evidence: mMessageSender.send(encryptedPayload);
The field is read outside the lock while doClose() sets it to null, which causes a null-pointer exception.
Copy the field to a local inside the lock and null-check it.
From the report
Evidence: mConnectionBackoffExecutor.schedule(this::doTryConnect, delay, TimeUnit.MILLISECONDS);
doTryConnect is documented as requiring the lock but runs unlocked, so it can open a socket after close(). NostrRelayScenario.java:209 and LocalAssociationScenario.java:94 and :213 do the same.
Schedule a lambda that takes the lock and checks the state first.
From the report
Evidence: prepareMessage(addressRaw ?: it.accounts.first().publicKey) … arrayOf(addressRaw!!)
For wallets without native sign-in, signing in without an explicit address always fails.
Compute the address once and use it for both the message and the signing call.
From the report
Evidence: set: config.authorizationResultCache.set, ... clear: config.authorizationResultCache.clear,
A class-based cache supplied by the caller breaks, and authorization persistence fails silently.
Wrap the methods in arrow functions.
From the report
Evidence: Bitmap iconBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
The intrinsic size can be -1 or 0, which throws. Base64.DEFAULT inserts line breaks into the data URI.
Use a bounded fallback size and Base64.NO_WRAP.
From the report
Evidence: implementation libs.androidx.junit.ktx
JUnit and androidx.test are pulled into every consuming app.
Move it to a test configuration.
From the report
Evidence: <link href="https://fonts.googleapis.com/css2?family=Inter+Tight:..." rel="stylesheet">
Every dApp's users contact Google's font servers without the dApp's consent, and the modal breaks under a strict content security policy.
Bundle the font, or use a system font stack.
From the report
Evidence: permissions: contents: write ... --allowedTools "...,Bash(git:*),...,Bash(gh api:*)"
The agent reads issue bodies and comments that anyone can write, while holding a write token and unrestricted git and API tools. A prompt injection could push to any branch or tag. The agent action is also pinned to a movable tag, here and in claude-investigate.yml.
Allow only exact commands, push only to claude/* branches, drop the generic API tool, and pin the action by commit.
From the report
Evidence: --allowedTools "Write,Bash(gh issue edit:*),Bash(gh issue close:*),...,Bash(gh api:*)"
The workflow runs on every new issue with no maintainer gate. Injected text could close or relabel other issues, or call any write endpoint.
Restrict the commands to the triggering issue, and remove the generic API tool.
From the report
Evidence: if: ${{ github.event_name == 'push' && github.event.push.base_ref == 'main' }}
Push events have no push object, so the condition is always false and the docs are never published.
Use github.ref == 'refs/heads/main'.
Info after review (16)
From the report
Evidence: Bip39PhraseUseCase.bip39EnglishWordlist[Random.nextInt(Bip39PhraseUseCase.bip39EnglishWordlist.size)]
kotlin.random.Random is not a cryptographic generator, so new recovery phrases are predictable. Every key derived from them is predictable too. Developers do load real funds into simulator-created seeds, and the code is a template for real vaults.
Generate entropy with SecureRandom (or libsodium) and derive the mnemonic from it with a vetted BIP39 library.
From the report
Evidence: fun toSeed(seedPhraseWordIndices: List<Int>): ByteArray { … require(size == 12 || size == 24)
Only the word count is validated. Randomly picked words rarely form a valid checksummed mnemonic, so a seed created here may not restore in any other wallet.
Use a proven BIP39 implementation for both generation (entropy to mnemonic) and validation (checksum word).
From the report
Evidence: RESET_SEED_VAULT_SIMULATOR_METHOD -> callResetSeedVaultSimulator()
Any app with the normal, user-grantable vault permission can call this method and delete every stored seed. An implementation copied from the simulator inherits a remote wipe.
Compile the method only into test builds, or require a signature-level permission.
From the report
Evidence: Log.d(TAG, "setSeedPhraseWord($index, $w)") … Log.d(TAG, "setPIN($p)")
Anyone with adb access or a bug report can rebuild the full recovery phrase and PIN. The seed details object is also logged whole (line 152, and SeedRepository.kt lines 149 and 185).
Delete these log lines and override the seed-details toString() to redact secrets.
From the report
Evidence: public static byte[] hkdfSHA256L16(@NonNull byte[] ikm, @NonNull byte[] salt) {
Hand-rolled cryptographic building blocks are easy to get subtly wrong. The same applies to the DER/P1363 converters in ECDSASignatures.java.
Use a maintained implementation, for example BouncyCastle's HKDF.
From the report
Evidence: public static byte[] schnorrSign(...) { // hardcoded zero randomness here
BigInteger point arithmetic is not constant-time, and the auxiliary randomness is fixed.
Use a vetted BIP-340 implementation and fresh auxiliary randomness.
From the report
Evidence: TODO("wrong. This is the expanded private key, not the Sodium secret key.")
Custom curve code with runtime TODO() throws is a trap for anyone who copies it.
Delete it, or replace it with a vetted library.
From the report
Evidence: 49152 + Math.floor(Math.random() * (65535 - 49152 + 1))
The port forms part of the association, so it should not be predictable. reflectorId.ts also scales 32 random bits over a 2^53 range.
Use crypto.getRandomValues with bias-free reduction.
From the report
Evidence: <uses-permission android:name="android.permission.INTERNET" />
Both sides use loopback sockets. Newer Android versions are introducing local-network permission gating.
Declare the permission where it applies and fall back gracefully when it is denied.
From the report
Evidence: onClick = onBiometricAuthorizationSuccess
Tapping the icon counts as biometric approval, and this ships in release builds.
Use BiometricPrompt in real flows, and keep the simulation in test variants only.
From the report
Evidence: <!-- <Root>/testkeystore/PrivilegedKey --> plus the release build using signingConfigs["simulator"] (build.gradle:55)
The privileged known-signer certificate is a public test key whose password is in the repository. Anyone can sign an app with it and obtain privileged vault access.
Keep test certificates in debug/test flavors only, and sign releases with a private key.
From the report
Evidence: require(selectionArgs.size == 1) { "Expected only 1 selectionArg; got $selectionArgs.size" }
Filtering on byte-array columns throws, string comparison trims only one side, and the error message interpolates the wrong value. The parser is safe against SQL injection.
Document the limit or implement it properly, and fix the message.
From the report
Evidence: result.authResult.signInResult?.run { TransactionResult.Success(this, result.authResult) }
Sign-in is returned as success without checking the signature, nonce or domain. The examples do not use a server nonce.
Document clearly that verification is the caller's job, or provide a verification helper.
mobile-wallet-adapter/examples/example-react-native-wallet/components/WalletProvider.tsx:57From the report
Evidence: await AsyncStorage.setItem(ASYNC_STORAGE_KEY, JSON.stringify(encodeKeypair(nextKeypair)));
The code is marked test-only, but it is easy to copy into a real wallet.
Keep it out of production copies, or use Keystore-backed storage.
seed-vault-sdk/cts/build.gradle:33From the report
Evidence: storePassword = 'gene...' (truncated)
This is acceptable for throwaway test keys, provided those keys never sign a distributed build. The same pattern appears in fakewallet/build.gradle and fakewalletreact/android/app/build.gradle.
Keep these keys out of every release pipeline.
From the report
Evidence: mAuthDb.close();
The initialised flag and the DAOs still point at the closed database, so the next session fails. Calling stop() without a prior start throws a null-pointer exception.
Guard stop(), reset the state, and reopen in start().