| Audited | Solana Mobile developer documentation, the Mobile Wallet Adapter libraries and specification, the publishing CLI and the tutorial applications |
|---|---|
| Date | 9 June 2026 |
| How it ran | earlier documentation audit; severities are as rated in that report and were not re-rated |
| Re-check, 11 October 2026 | 8 fixed and 2 partly fixed on the pages the report cited; the other findings were not re-checked on that date |
Public commits to the Solana Mobile documentation that corrected the cited pages:
- 812c43d209886f63c5e1aaa2f1f69140ed4889d1 (21 June 2026)
- dca752d20657acff15f6b3ba434b794c27448a28 (23 June 2026)
- da2f11498e6372909fd905486be9c982516ff7b4 (27 August 2026)
- 142c805b3147efa4c176c300574853214f07a311 (31 August 2026)
- c2699d9423e24dd8465c8e5ee1b3931280cc932f (9 September 2026)
Related: Solana Mobile developer documentation (October 2026) · Solana Mobile SDKs: mobile-wallet-adapter and seed-vault-sdk (October 2026) · Solana Mobile stack, third audit (September 2026)
Severities as published in our original report; these findings were not re-reviewed by hand the way the sample audits were. The 11 October 2026 re-check status is shown under each finding where it maps.
All June findings
mobile-wallet-adapter/spec/spec.mdmobile-wallet-adapter/spec/spec.md line 19 declares **Version: 2.0.0** but the changelog table (lines 23-27) lists three releases: 1.0.0, 2.0.0, and 2.1.0 — Add optional wallet icon parameter to authorize response. A 2.1.0 release row exists in the changelog without bumping the header version field. Reader cannot tell which version the spec body describes.
align header **Version:** field with most-recent changelog entry; OR mark un-released changelog rows explicitly (e.g. "2.1.0 — DRAFT").
chain vs cluster parameter — three-way inconsistency at canonicity layerrecipes/, get-started/react-native/Fixed on the page our report cited; still in place on 11 October 2026
Docs site uses FOUR forms across four files: (a) get-started/react-native/setup.md prop table line 38 — chain: string (canonical). (b) get-started/react-native/invoke-mwa-sessions-directly.md lines 96, 258, 448 — cluster: 'solana:devnet' (deprecated form ×3); line 146 — chain: 'solana:devnet' (canonical ×1). (c) recipes/mobile-wallet-adapter/caching-wallet-authorization.md line 193 — cluster: "devnet" (legacy pre-1.0 form, bare cluster name without solana: prefix). (d) recipes/solana-development/anchor-integration.md lines 83, 96, 193 — cluster: RPC_ENDPOINT (variable contains an RPC URL like https://api.devnet.solana.com, not a chain identifier — WRONG VALUE TYPE regardless of key name).
Standardize on chain: 'solana:<network>' per current spec; mark cluster: examples as deprecated form with migration pointer; correct cluster: RPC_ENDPOINT to chain: 'solana:devnet' separately from passing RPC_ENDPOINT to new Connection(...).
})); over-close in Anchor reciperecipes/solana-development/anchor-integration.mdFixed on the page our report cited; still in place on 11 October 2026
recipes/solana-development/anchor-integration.md has three locations with extra ) closing in wallet.authorize(...) invocation: lines 85 (}));), 98 (}));), 195 (}));). The authorize call opens with await wallet.authorize({ (one {, one () and should close with }) — the snippets close with })). Copy-paste reproduction fails to compile.
Remove the extra ) at each location.
get-started/react-native/invoke-mwa-sessions-directly.mdFixed on the page our report cited; still in place on 11 October 2026
get-started/react-native/invoke-mwa-sessions-directly.md defines APP_IDENTITY twice (lines 88-92, 134-138), both with uri: 'https://yourdapp.com' immediately followed (without a comma) by icon: "favicon.ico". JavaScript object literal requires comma between properties; this is a syntax error. Two occurrences.
Add trailing comma after uri: value at both locations.
awaitget-started/react-native/invoke-mwa-sessions-directly.mdFixed on the page our report cited; still in place on 11 October 2026
invoke-mwa-sessions-directly.md Versioned Transactions tab (line 255) and Legacy Transactions tab (line 321) both invoke await transact((wallet) => { — the callback is NOT declared async but the body uses await wallet.authorize(...). JavaScript syntax error at module evaluation.
Mark callbacks async (wallet) => at both locations.
@solana/web3.jsget-started/react-native/invoke-mwa-sessions-directly.mdFixed on the page our report cited; still in place on 11 October 2026
invoke-mwa-sessions-directly.md lines 247-252 (Versioned) and 313-318 (Legacy) import sendTransaction and confirmTransaction as top-level exports of @solana/web3.js. Both are methods on the Connection class, not top-level exports. Build-time error "Module has no exported member 'sendTransaction'".
Remove these imports; call connection.sendTransaction(...) / connection.confirmTransaction(...) instead.
await and ,→. typo in signMessages callget-started/react-native/invoke-mwa-sessions-directly.mdPartly fixed on the page our report cited; re-checked 11 October 2026
invoke-mwa-sessions-directly.md lines 453-456: const signedMessages = wallet.signMessages({ lacks await; AND addresses: [authorizationResult.accounts[0].address]. ends with a DOT instead of a COMMA before payloads: [...]. Two bugs in 4 lines. Code parses to access a property .payloads on an array, then () invocation — runtime error.
Prepend await; replace ]. with ],.
transact() parenthesis in SIWS sampleget-started/react-native/invoke-mwa-sessions-directly.mdFixed on the page our report cited; still in place on 11 October 2026
invoke-mwa-sessions-directly.md SIWS example (lines 184-197): const signInResult = await transact(async (wallet: Web3MobileWallet) => { ... }); — but the snippet ends at line 197 with } only; the closing ); is missing. Causes syntax error.
Add ); after the closing }.
authorizationResult.publicKeyrecipes/solana-development/anchor-integration.mdFixed on the page our report cited; still in place on 11 October 2026
recipes/solana-development/anchor-integration.md line 210 sets feePayer: authorizationResult.publicKey,. Per MWA spec + reference docs, AuthorizationResult exposes accounts (array) with accounts[0].address (base64-encoded). .publicKey is not a defined field — yields undefined, transaction construction silently broken.
Replace with feePayer: new PublicKey(toByteArray(authorizationResult.accounts[0].address)).
recipes/solana-development/using-anchor-programs.mdrecipes/solana-development/using-anchor-programs.md line 23 inside a build.gradle.kts block: implementation('io.github.funkatronics:kborsh:${versions.KOTLIN_KBORSH_VERSION}'). Single-quote string literal is Groovy DSL syntax; Kotlin DSL (.kts) requires double quotes. Other two lines in same block correctly use "...". Build fails on copy.
Replace '...' with "...".
get-started/react-native/invoke-mwa-sessions-directly.mdFixed on the page our report cited; still in place on 11 October 2026
invoke-mwa-sessions-directly.md lines 36-39 (npm tab inside <CodeGroup>): npm install <pkg1> \ newline <pkg2> \ newline — a TRAILING backslash after the second package name with no further line continuation. Shell waits for input on npm install.
Remove trailing backslash from final package line.
recipes/solana-development/using-anchor-programs.mdusing-anchor-programs.md lines 199-203 use if (response.result) {...} else if (response.error) {...}. response.result is a value (likely a String or nullable String), not Boolean — Kotlin if requires Boolean; should be if (response.result != null). Same issue for response.error. Additionally response.error.message accesses a field without !! or ?.. Reader copy-pastes; compiler rejects.
Rewrite as val result = response.result; if (result != null) { ... } else { response.error?.let { println("Failed: ${it.message}") } } or analogous.
recipes/solana-development/using-anchor-programs.mdusing-anchor-programs.md build.gradle.kts uses ${versions.KOTLIN_WEB3_SOLANA_VERSION}, ${versions.KOTLIN_RPC_CORE_VERSION}, ${versions.KOTLIN_KBORSH_VERSION} without narrating where versions comes from. Reader copy-pastes; gradle fails to resolve property. Convention is gradle/libs.versions.toml + libs.kotlin.web3.solana.get() or similar.
Either inline concrete versions (consistent with kotlin/installation.md line 20-23 which uses literals like :2.0.3) OR add a gradle.properties snippet showing versions.KOTLIN_*=….
build.gradle exampledapp-store/build-and-sign-an-apk.mddapp-store/build-and-sign-an-apk.md Native Android tab (lines 109-114) recommends inline storePassword "your_keystore_password" + keyPassword "your_key_password" in app/build.gradle. Even with placeholder strings, this is an explicit anti-pattern (keystore passwords committed alongside source). Vendor's own publisher policy at dapp-store/publisher-policy.md does not enforce this, but consumer-side outcome is leaked credentials.
Demonstrate the gradle.properties (gitignored) + signingConfigs { dappStore { storePassword providers.gradleProperty('KEYSTORE_PASS').get(); ... } } pattern; add note "never commit keystore passwords".
signTransactions documented as primary API without deprecation markerget-started/react-native/invoke-mwa-sessions-directly.md, mobile-wallet-adapter/spec/spec.mdmobile-wallet-adapter/spec/spec.md line 149 explicitly lists solana:signTransactions under Deprecated Features: "deprecated, but can be supported by wallet endpoints to maintain backwards compatibility". Reader treats both as live current API.
recipes/mobile-wallet-adapter/migrating-to-wallet-standard.mdPartly fixed on the page our report cited; re-checked 11 October 2026
recipes/mobile-wallet-adapter/migrating-to-wallet-standard.md line 34: *"Ensure registerMwa is invoked in a non-SSR context. if you're using a framework with Server Side Rendering (e.g Next.js),"* — sentence ends with a trailing comma. Reader unsure if instruction is complete.
Complete the sentence ("...wrap the registration in a useEffect hook or invoke from a client-only entry point.")
associcationget-started/react-native/invoke-mwa-sessions-directly.mdinvoke-mwa-sessions-directly.md line 30 — *"Calling transact dispatches an assocication intent..."*. Should be "association".
Spelling fix.
ProgramDerivedAddres in narrativerecipes/solana-development/using-anchor-programs.mdusing-anchor-programs.md line 54 references ProgramDerivedAddres (missing one s) in narrative text. The code block below correctly uses ProgramDerivedAddress. Reader scanning the narrative for the class name may search for the wrong identifier.
Spelling fix.
index.md, sample-apps/sample_app_overview.mdindex.md is ~95% JSX (<HeroCard>, <CardGroup>, <Card>, <Columns>, inline <div> with inline CSS) — out of 125 lines, only ~5 carry semantic content. Mirror snapshot serving any plain-markdown reader (LLM ingestion, offline browser, this audit) loses navigational structure. sample_app_overview.md similarly duplicates content across 4 Tab variants (All / RN / Android / Testing) with identical Card bodies — ~600 lines of mostly-JSX vs ~20 unique items.
If vendor maintains LLM-readable / plain-markdown surface (which Mintlify llms.txt + .md append trick suggests they do), strip JSX wrappers OR provide markdown fallback bullets. Consumer-side: token-budget-tradeoff acceptable for ingestion.
Mock MWA Wallet, Kotlin MobileWalletAdapter class implementation, and full TypeScript SDK source all live in repos.
attest_origin / ERROR_ATTEST_ORIGIN_ANDROID) is undocumented at docs.solanamobile.commobile-wallet-adapter/spec/spec.md lines 1077–1161 vs SolanaMobileDocs_full/ (zero coverage)MWA spec lines 1056–1161 specify a sophisticated Web-dapp identity verification flow involving (a) Trusted Web Activities + Digital Asset Links to bring browser security model into wallet trust, (b) wallet-hosted HTML attestation script with keypair, (c) ERROR_ATTEST_ORIGIN_ANDROID error with context/challenge/attest_origin_uri data, (d) dApp loads attestation script in invisible iframe, (e) postMessage origin-attestation handshake, (f) signed-challenge return + retry of authorize. Mirror grep for attest_origin|ERROR_ATTEST_ORIGIN|origin.attestation over SolanaMobileDocs_full/ returns ZERO matches across all 57 docs pages. This is the SOLE mechanism by which web dApps can be identity-verified by wallets per the spec. Without consumer-facing documentation, web dApp integrators cannot implement origin attestation and wallets must either reject all web dApps as unverified OR extend trust without verification.
author a dedicated documentation page covering origin attestation flow with example wallet-side attestation script + dApp-side iframe loader. Reference from recipes/mobile-wallet-adapter/ and from recipes/mobile-wallet-adapter/migrating-to-wallet-standard.md (since web dApps using Wallet Standard registration must implement origin attestation if calling wallets enforce it).
docs.solanamobile.commobile-wallet-adapter/spec/spec.md lines 142–350 vs SolanaMobileDocs_full/ (zero coverage)MWA spec lines 142–350 define an entire new transport mechanism: Nostr relay events of kind 20012 (provisional NIP), session_identifier derivation via SHA-256 of association_token, CONNECT event flow, SESSION_END signal, P-256 ECDSA Nostr keypair generation. Mirror grep for Nostr|nostr over SolanaMobileDocs_full/ returns ZERO matches. The Nostr URI scheme solana-wallet:/v1/associate/{local|remote}/nostr has no consumer-facing reference. NEW protocol feature with no migration / quickstart / recipe documentation. Wallet endpoints implementing only the legacy WebSocket transport will silently fail association attempts from MWA 2.1 dApps using Nostr transport.
author a dedicated documentation page covering Nostr transport (when to choose; how to configure; reflector vs Nostr comparison; relay selection criteria; security model). Update solana-mobile-stack/mobile-wallet-adapter.md to enumerate transports including Nostr.
JSON.parse()mobile-wallet-adapter/spec/spec.md (5+ ranges enumerated)mobile-wallet-adapter/spec/spec.md lines 591–603 (authorize Params), 622–646 (authorize Result), 716–723 (deauthorize Params), 1099–1108 (authorize+clone_authorization Params modifications), 1140–1147 (ERROR_ATTEST_ORIGIN_ANDROID error data) — all use curly quotes (Unicode U+201C " and U+201D ") instead of straight ASCII quotes (U+0022 "). Multiple JSON examples affected. A reader extracting these as JSON literals via copy-paste gets parse errors. Pervasive across the spec body. Examples elsewhere in spec (lines 230, 261, 293, 322, 357, 412–425, 449–462) DO use straight quotes — inconsistency suggests JSON examples were composed/edited in a tool that auto-curlied quotes for some sections.
bulk replace " → " and " → " within fenced JSON code blocks (lines 591–603, 622–646, 716–723, 1099–1108, 1140–1147 at minimum; full grep recommended). Add CI gate forbidding curly quotes inside fenced code blocks.
qd signature verification at spec line 499mobile-wallet-adapter/spec/spec.md line 499— lowercase qd should be Qd to match the X9.62-encoded public keypoint identifier used everywhere else in the spec.
Spelling fix.
chain accepted-values enumeration mixes canonical + legacy without deprecation markersmobile-wallet-adapter/spec/spec.md line 612Spec line 612: *"chain: (optional) if set, the chain identifier... supported values include solana:mainnet, solana:testnet, solana:devnet, mainnet-beta, testnet, devnet."* The canonical CAIP-2 values (solana:*) and the legacy bare values (mainnet-beta/testnet/devnet) appear coequal. The cluster PARAMETER is labeled as alias-deprecated at line 617, but the legacy bare VALUES of chain have no (deprecated) annotation within the accepted-values list. The deprecation lives at the parameter-name axis (cluster is alias) and at the value-format axis (bare values should be migrated to CAIP-2) but the latter is undeclared in the spec body.
Reformat spec line 612 as "supported values: canonical (CAIP-2) solana:mainnet, solana:testnet, solana:devnet; legacy mainnet-beta, testnet, devnet (will be removed in MWA 3.0; migrate to CAIP-2 form)" — separate the two value families with an explicit deprecation timeline.
wait_for_commitment_to_send_next_transaction option has no version annotationmobile-wallet-adapter/spec/spec.md line 819Spec line 819 introduces this option for sign_and_send_transactions without indicating whether it's an MWA 2.0 or 2.1 addition. Wallet implementations need to determine support; absence of version annotation forces consumer-side capability probing via get_capabilities or empirical testing.
Add changelog row with version-introduction; annotate at parameter site (wait_for_commitment_to_send_next_transaction: (since MWA 2.x)).
solana.core.Message.from(byteArray) deserialization bugMinty-fresh/libs/mintycore/src/main/java/com/solanamobile/mintyfresh/mintycore/usecase/PerformMintUseCase.kt lines 217–225Minty-fresh/libs/mintycore/src/main/java/com/solanamobile/mintyfresh/mintycore/usecase/PerformMintUseCase.kt lines 217–225: code comment "there is a deserialization bug in solana.core.Message.from(byteArray) so have to build up the Message (and Transaction) object manually (for now)". The workaround manually rebuilds the Transaction object from recentBlockhash + feePayer + instructions + signature instead of using the deserializer. The vendor's own reference app shipping with a documented library-bug workaround is non-trivial; the bug appears to be long-standing ((for now) suggests acknowledged but unfixed). Consumer-side integrators following this reference app will adopt the workaround without surface awareness of the underlying bug.
fix the underlying solana.core.Message.from(byteArray) deserialization bug at the SDK level; OR document the workaround as a permanent integration recipe in recipes/solana-development/; OR mark Message.from(byteArray) as @Deprecated with replacement pointer.
Minty-fresh/libs/mintycore/.../PerformMintUseCase.kt line 246Minty-fresh/libs/mintycore/.../PerformMintUseCase.kt line 246: delay(600) before first confirmation poll attempt. Inline-comment line 245: // https://www.validators.app/ping-thing.
Extract const val INITIAL_CONFIRMATION_POLL_DELAY_MS = 600 with KDoc citing the rationale + external reference.
blockchain is Solana.Devnetmobile-wallet-adapter/android/clientlib-ktx/src/main/java/com/solana/mobilewalletadapter/clientlib/MobileWalletAdapter.kt line 38MobileWalletAdapter.kt line 38: var blockchain: Blockchain = Solana.Devnet. Production apps integrating the SDK must explicitly set Solana.Mainnet; forgetting silently directs the dApp to devnet. The DEFAULT for a public SDK is the devnet form (development environment), not the mainnet form (production environment). This is fail-open behavior for new integrators.
change default to Solana.Mainnet; OR add a require() check on first transact() invocation if blockchain is unset; OR document the default prominently in get-started/kotlin/setup.md with a "you MUST set this for production" callout.
The docs site does not enumerate either subprotocol name (mirror grep). Consumer code attempting to manually upgrade WebSocket connections lacks this reference.
add subprotocol reference in solana-mobile-stack/mobile-wallet-adapter.md (low cost; informational).
walet typomobile-wallet-adapter/spec/spec.md line 332— "This allows the walet endpoint to cater its UX accordingly..." (line 332). Single character missing.
Spelling fix (walet → wallet).
mobile-wallet-adapter/android/walletlib/src/main/java/.../protocol/MobileWalletAdapterServer.java (zero matches for attest_origin|ERROR_ATTEST_ORIGIN|originAttestation across walletlib body) + .../scenario/AuthorizeRequest.java lines 135–142MWA spec lines 1056–1161 define ERROR_ATTEST_ORIGIN_ANDROID (code -100) as the wallet endpoint's mechanism to require web-dApp challenge-response identity verification. MobileWalletAdapterServer.java enumerates exactly 5 error codes (ERROR_AUTHORIZATION_FAILED, ERROR_NOT_SIGNED, ERROR_INVALID_PAYLOADS, ERROR_NOT_SUBMITTED, ERROR_TOO_MANY_PAYLOADS, ERROR_CLUSTER_NOT_SUPPORTED per lines 220–222, 629–637, 996–1010) — **zero handling of ERROR_ATTEST_ORIGIN_ANDROID**. Scenario layer AuthorizeRequest.java exposes completeWithDecline() (line 135) and completeWithClusterNotSupported() (line 139) but no completeWithAttestOriginAndroid(context, challenge, attest_origin_uri). Wallet apps building on walletlib cannot implement spec lines 1056–1161 origin-attestation enforcement without forking walletlib. This is the SOLE web-dApp identity verification mechanism per spec.
add MobileWalletAdapterServer.ERROR_ATTEST_ORIGIN_ANDROID = -100 constant; add AuthorizationResult overload or new AuthorizeRequest.completeWithAttestOriginAndroid(context: ByteArray, challenge: ByteArray, attestOriginUri: Uri) callback signature; wire scenario → MobileWalletAdapterServer to emit {code: -100, message, data: {context, challenge, attest_origin_uri}} per spec lines 1140–1149. Document wallet-side reference attestation script + Digital Asset Links flow at recipes/mobile-wallet-adapter/.
association/RemoteAssociationUri.java lines 33–39 (validate only REMOTE_PATH_SUFFIX); transport/websockets/ReflectorWebSocket.java lines 63–66 (WEBSOCKETS_PROTOCOL + WEBSOCKETS_BASE64_PROTOCOL only)Spec lines 142–350 define Nostr URI form solana-wallet:/v1/associate/{local|remote}/nostr + kind-20012 relay events + session_identifier derivation via SHA-256 of association_token + P-256 ECDSA Nostr keypair. RemoteAssociationUri.java (78 lines) handles only /v1/associate/remote/ reflector form with parseReflectorHostAuthority + parseReflectorId; **no RemoteNostrAssociationUri analog exists** in association/. AssociationUri.parse (lines 82–92) tries Local + RemoteWebSocket variants only and silently returns null on parse failure. Wallet apps based on walletlib are silently incompatible with MWA 2.1 Nostr-originated association URIs — the URI doesn't parse, returns null, and connection fails opaquely.
implement RemoteNostrAssociationUri parsing /v1/associate/remote/nostr URIs + Nostr-transport scenario class; OR explicitly document at walletlib README + spec that walletlib is a WebSocket-only reference implementation and Nostr transport requires a separate library.
MobileWalletAdapterServer.AuthorizationResult constructor throws when walletIcon is null despite @Nullable field annotationprotocol/MobileWalletAdapterServer.java lines 355–356 + 385–411Lines 355–356 declare @Nullable public final Uri walletIcon;. Lines 405–410 constructor body: if (walletIcon != null && walletIcon.getScheme() != null && walletIcon.getScheme().equals("data")) { this.walletIcon = walletIcon; } else { throw new IllegalArgumentException("wallet icon URI must be a data URI"); }. **If walletIcon == null, the if is false → else branch throws**. The deprecated constructor chain at lines 385–391 calls this(..., null, signInResult) passing null walletIcon → runtime IAE. Any caller using the deprecated overload to preserve backward compatibility instead hits unconditional IAE. Annotation lie.
either (a) accept null walletIcon (allow this.walletIcon = walletIcon when null), or (b) remove @Nullable annotation, or (c) update deprecated constructor chain to pass a sentinel default walletIcon. Add unit test verifying deprecated-constructor call path.
BaseScenario.finalize() (lines 125–127) uses Java's deprecated finalize() for Looper cleanupscenario/BaseScenario.java lines 124–127 + 172finalize() is deprecated since Java 9 (JEP 421 removal in Java 18+), runs unpredictably under memory pressure on Android, and may not run at all in low-memory conditions. Using it to call mIoLooper.quitSafely() is fragile — the Looper may stay alive indefinitely (resource leak). Modern Android pattern is explicit close() (already declared abstract at line 172) or AutoCloseable. Coverage of close() callers is operator-deferred — the abstract method exists but is not enforced via try-with-resources.
remove finalize(); document that callers MUST invoke close() explicitly; OR implement AutoCloseable to enable try-with-resources idiom. Consider adding a logging-only finalizer warning if close() was not called (current Android idiom).
assert(...) for runtime safety checks throughout — disabled by default in productionJava assertions require -ea JVM flag to evaluate; on Android production builds assertions are NEVER evaluated. Walletlib uses assert(...) for non-trivial preconditions: BaseScenario.java lines 132 (mSessionEstablishedFuture == null), 140 (mActiveSessionId == null && mSessionEstablishedFuture != null), 148 (mSessionEstablishedFuture != null); MobileWalletAdapterServer.java lines 235, 649, 650, 1021, 1022 (result-non-null + signed-payload-length checks); ReflectorWebSocket.java lines 71 (mState == CONNECTING || mState == CLOSED), 87–88 (mState == CONNECTED || ... || ... CLOSING), 106–107, 126 (mState != NOT_CONNECTED); AuthRepositoryImpl.java lines 234 (!authRecord.isRevoked()), 266 (payloadHmac.length == HMAC_LENGTH_BYTES), 422 (!authRecord.isRevoked()). These checks fail-open in production — preconditions are silently violated rather than raising IllegalStateException. **Library code SHOULD use defensive checks (Preconditions.checkState, Objects.requireNonNull, explicit IAE throws)** that survive production builds.
replace assert(...) with if (!cond) throw new IllegalStateException(...) for hard invariants; OR if (BuildConfig.DEBUG) check(cond) for debug-only checks; OR document at module README that assertions are advisory only. Lint rule (UseObjectsRequireNonNull / Error Prone AssertEqualsArgumentOrderChecker family) could enforce.
(#44): support multiple addresses at BaseScenario.java line 369 — signMessages rejects multi-address requestsscenario/BaseScenario.java lines 361–370Spec defines sign_messages addresses as a JSONArray (server line 767–778 unpacks array). BaseScenario catches IllegalArgumentException from the SignMessagesRequest constructor and completes with RequestDeclinedException("Unexpected address; not signing message"). Comment indicates the issue tracker bug #44 — multi-year limitation (file Copyright 2024 but TODO predates per typical lifecycle). Consumer-side dApps requesting multi-address sign_messages against walletlib-based wallets are silently rejected.
track and close #44; OR document at spec line 765 that single-address-per-request is the current implementation limit (positive-form documentation if intentional).
authorization/AuthRepositoryImpl.java line 186 (comment)AuthRepositoryImpl.java line 186 comment: "Look up the identity secret key for the key specified in this JWT". Actual format (lines 241–272): JSON object {typ, iid, tid} UTF-8-encoded + 32-byte HMAC-SHA256 appended + Base64. Not JWT (no header.payload.signature dot-separated structure; not Base64URL; no alg field; no header at all). Naming gap may mislead reviewers expecting JWT semantics — e.g., assuming JWT signature verification interop with standard jose/jjwt libraries.
rename comment to "Look up the identity secret key for the key specified in this auth token"; OR migrate format to actual JWT (JWS Compact Serialization) for interop. Code review hygiene fix.
revokeNonReissuableAuthRecord treats authRecordAgeMs < 0 (clock-backwards) as "issued in the future" and revokes the auth recordauthorization/AuthRepositoryImpl.java lines 282–300AuthRepositoryImpl.java lines 287–289: if (authRecordAgeMs < 0) { Log.w(TAG, "AuthRecord issued in the future; revoking"); revoke = true; }. Routine system events that move clock backwards (NTP sync after offline period, daylight savings end, manual user clock change, OS timezone correction) silently revoke ALL auth records whose issued timestamp now exceeds System.currentTimeMillis(). User experience: wallet app loses all dApp authorizations without explanation; all dApps must re-authorize. Zero clock-skew tolerance.
introduce a CLOCK_SKEW_TOLERANCE_MS (e.g., 5 minutes) and only revoke if authRecordAgeMs < -CLOCK_SKEW_TOLERANCE_MS; OR use monotonic clock for age computation (Android SystemClock.elapsedRealtime() paired with a calibration offset). Document the time-source dependency.
disconnect() does NOT call walletAdapter.deauthorize() — cross-scaffold inconsistency with RN scaffoldsolana-kotlin-compose-scaffold/app/src/main/java/.../viewmodel/MainViewModel.kt lines 259–270 vs solana-mobile-dapp-scaffold/template/components/providers/AuthorizationProvider.tsx lines 128–137solana-kotlin-compose-scaffold/.../MainViewModel.kt lines 259–270 disconnect() only invokes persistenceUseCase.clearConnection() (clears local SharedPreferences). The wallet endpoint retains the auth token until natural expiry or purge. By contrast, solana-mobile-dapp-scaffold/template/components/providers/AuthorizationProvider.tsx lines 128–137 deauthorizeSession correctly calls await wallet.deauthorize({auth_token: authorization.authToken}). Same vendor publishes two scaffolds with divergent deauthorization discipline: consumer apps copying the Kotlin scaffold inherit the gap (wallet-side auth_token persists indefinitely; privacy + auth-lifetime concern); consumer apps copying the RN scaffold do not.
update Kotlin scaffold disconnect() to invoke walletAdapter.transact(sender) { deauthorize(authResult.authToken) } (or equivalent clientlib idiom) before clearing local persistence; verify cross-scaffold parity at next vendor release; add scaffold-parity CI check.
solana-kotlin-compose-scaffold/app/src/main/java/.../di/SolanaKotlinComposeScaffoldModule.kt lines 30–36solana-kotlin-compose-scaffold/.../di/SolanaKotlinComposeScaffoldModule.kt lines 30–36 providesMobileWalletAdapter constructs MobileWalletAdapter(connectionIdentity = ConnectionIdentity(...)) without specifying blockchain. Scaffold pattern propagates the fail-open default: consumer integrators copying the scaffold for production deploys silently ship a Devnet-pointed wallet adapter unless they remember to override. No inline comment / TODO warns the consumer.
either (a) set explicit blockchain = Solana.Mainnet in the scaffold DI module (since scaffolds usually point users at the production-target default) with a comment noting "switch to Solana.Devnet for development"; (b) add a prominent block comment // REPLACE-ME: set blockchain explicitly for production deploys; (c) parameterize via BuildConfig field like the existing RPC_URI pattern (consistent with line 44 of MainViewModel.kt).
solana-kotlin-compose-scaffold/.../di/SolanaKotlinComposeScaffoldModule.kt lines 14–16 + solana-mobile-dapp-scaffold/template/components/providers/AuthorizationProvider.tsx lines 66–70Kotlin: solanaUri = "https://solana.com", identityName = "Solana", iconUri = "favicon.ico" (DI module lines 14–16). RN: name: 'React Native dApp', uri: 'https://solanamobile.com', icon: 'favicon.ico' (AuthorizationProvider.tsx lines 66–70). Consumer apps shipping with these defaults misrepresent their identity to wallet apps: the MWA UI displays "Solana" or "React Native dApp" instead of the consumer's actual app name; the wallet's identity-record DB associates the consumer app's auth tokens with the wrong vendor identity URI. No inline REPLACE-ME marker or build-time warning.
insert prominent // REPLACE-ME: identity values reach the wallet UI; update before production ship comment at each site; OR use placeholders like "YOUR_APP_NAME" / "https://YOUR_APP_DOMAIN/" that fail-loud at first authorize attempt (wallet endpoint validates per server lines 142–145 identity.uri must be an absolute, hierarchical URI); OR introduce build-time validation against a deny-list of scaffold defaults.
cluster parameter on authorizesolana-mobile-dapp-scaffold/template/components/providers/AuthorizationProvider.tsx line 120solana-mobile-dapp-scaffold/template/components/providers/AuthorizationProvider.tsx line 120: wallet.authorize({ cluster: RPC_ENDPOINT, identity: APP_IDENTITY }). The RN scaffold transmits the deprecated alias on every fresh authorize call, training consumer integrators to follow the legacy parameter shape.
update scaffold to use chain: RPC_ENDPOINT (or CAIP-2 form like solana:devnet/solana:mainnet); add comment indicating cluster is deprecated alias kept for back-compat at wallet endpoint.
AuthorizationFuture.processResult populates per-account chains[] and features[] arrays with the FIRST element repeated N times instead of element-Nmobile-wallet-adapter/android/clientlib/src/main/java/.../protocol/MobileWalletAdapterClient.java lines 308–323Lines 312–314 for (int c = 0; c < chainsArr.length(); c++) { chains[c] = chainsArr.getString(0); } — note getString(0), not getString(c). Same bug at lines 319–322 for features[c] = featuresArr.getString(0). Result: an account with chains: ["solana:devnet", "solana:mainnet"] parses into String[] chains = ["solana:devnet", "solana:devnet"]. Consumer dApps that key request routing by account.chains or feature dispatch by account.features get silently wrong data — every chain entry mirrors index 0, every feature entry mirrors index 0. Defect-class: classic copy-paste loop-index typo. The arrays still have correct length — so simple chains.length checks pass; only per-index inspection surfaces the gap.
Replace chainsArr.getString(0) with chainsArr.getString(c) at line 313; replace featuresArr.getString(0) with featuresArr.getString(c) at line 321. Add unit test for AuthorizationFuture parsing JSON with multi-entry chains + multi-entry features; assert per-index equality with the source array.
createRequestUniqueId for dApp Store publication attestation uses Math.random() instead of cryptographically secure RNGdapp-publishing/packages/core/src/portal/attestation.ts lines 20–28Lines 20–28 generate a 32-char numeric ID from charset 0123456789 via Math.random() — V8's Math.random is XorShift128+ (not CSPRNG). The function name + usage context (request_unique_id field of a signed attestation {slot_number, blockhash, request_unique_id}) indicate replay-resistance dependency: the attestation's freshness depends jointly on blockhash recency and per-request uniqueness. With Math.random(), an attacker observing past request_unique_id values can fit the XorShift128+ state in seconds (well-known attack on Math.random); subsequent IDs become predictable. Pre-generated signed attestations could be replayed at validator-acceptable blockhash windows. Cryptographic-axis weakness; security-class.
Replace Math.random()-based generation with crypto.randomUUID() (built-in; 122 bits CSPRNG entropy) or crypto.randomBytes(16).toString('hex'). Both produce non-predictable IDs without external dependencies. Adjust attestation server-side validator if it requires the all-digit shape; otherwise update to base64url/hex shape.
assert(...) recurrence at clientlib — same fail-open-in-production pattern as V3 B38 walletlib (cross-axis same-vendor sibling SDK)mobile-wallet-adapter/android/clientlib/src/main/java/.../protocol/MobileWalletAdapterClient.java (2 sites) + .../scenario/LocalAssociationScenario.java (10 sites)Java assert(...) requires -ea JVM flag and is NEVER enabled in Android production builds. Clientlib uses assert(...) for non-trivial preconditions: MobileWalletAdapterClient.java:695 (numExpectedPayloads > 0 at unpackResponsePayloadArray), MobileWalletAdapterClient.java:727 (numExpectedBooleans > 0 at unpackResponseBooleanArray), LocalAssociationScenario.java:156, 165, 174, 185, 194, 204, 226, 241, 250, 264 (state-machine invariants + mSessionEstablishedFuture null-state guards). Production wallet apps using clientlib never evaluate these preconditions — state-machine invariants silently violated rather than raising IllegalStateException.
Cross-SDK consistency (apply at both walletlib and clientlib in same release).
signMessagesDetachedmobile-wallet-adapter/android/clientlib/src/main/java/.../protocol/MobileWalletAdapterClient.java lines 1018–1027Lines 1018–1026 contain inline comment: "Workaround: some wallets have been observed to only reply with the message signature. This is non-compliant with the spec, but in the interest of maximizing compatibility, detect this case and reuse the original message." Behavior: if a wallet returns only the 64-byte signature payload (no signed message prefix), signMessagesDetached substitutes the original mMessages[i] as the "signed message" in the result. The substitution is invisible to the dApp caller — result.messages[i].message is whatever the dApp passed in, not necessarily what the wallet endpoint actually saw/signed. Privacy axis: if a non-compliant wallet returns the signature but signed a slightly-different message (UTF-8 normalization, trimming whitespace, etc.), the dApp's result claims the original-message was signed when it was not. Dapps that audit signedMessage for tampering get false-positive PASS.
parseKeypair swallows all error types and returns undefined; caller's downstream usage is undefined-as-Keypair TypeErrordapp-publishing/packages/cli/src/cli/signer.ts lines 12–24 + downstream CliSetup.ts:373–379cli/signer.ts lines 12–24: try-block reads the keypair file + JSON-parses + constructs Keypair.fromSecretKey(...). Catch-block ignores the error entirely and displays generic message "Something went wrong when attempting to retrieve the keypair at <path>". Function then implicitly returns undefined. Caller path at CliSetup.ts:373–379 loadSignerKeypair checks if (!keypair) throw new Error('Failed to load the signer keypair') — recovers from undefined but the original error context is irrecoverable (file-not-found vs malformed JSON vs wrong secret-key length vs permission denied all yield the same generic message). Consumer debugging requires re-running with strace or manually inspecting the file.
Surface the original error: try { ... } catch (error) { const detail = error instanceof Error ? error.message : String(error); showMessage('KeyPair Error', \Failed to load keypair from \${pathToKeypairFile}: \${detail}\, 'error'); throw error; }. Either rethrow OR document that parseKeypair returning undefined indicates a multi-cause failure. Eliminate the if (!keypair) rescue at loadSignerKeypair — if parseKeypair throws, the calling stack carries diagnostic information.
checkForSelfUpdate hard-blocks CLI on minor version mismatchdapp-publishing/packages/cli/src/cli/selfUpdate.ts lines 51–58cli/selfUpdate.ts lines 51–58: if (latest.major > current.major || latest.minor > current.minor) throw new Error('Please update to the latest version of the dApp Store CLI before proceeding...'). The error throw kills CLI execution entirely. Behavior: a publisher running CLI vN.5.0 who hasn't upgraded to vN.6.0 (released yesterday) gets blocked at the very first invocation in a CI/CD pipeline. No grace period for minor versions; no operator opt-out flag at this gate (the gate fires before enforceSelfUpdatePolicy reads --skip-self-update). Ecosystem norms: minor versions are non-breaking; blocking on minor is overly strict.
Restrict hard-block to MAJOR version mismatch only (if (latest.major > current.major)); emit WARNING for minor version mismatch (if (latest.minor > current.minor) console.warn('A newer minor version is available: ...')); allow user to suppress warning via --skip-self-update-warnings (independent of --local-dev). Network-error path (registry down) currently kills CLI; add try-catch around notifier.fetchInfo() so registry outages don't block publishers.
attestation.ts returns payload AND attestationPayload fields pointing at the same signedMessageBufferdapp-publishing/packages/core/src/portal/attestation.ts lines 62–65 + dapp-publishing/packages/cli/src/portal/workflowClient.ts lines 537–555Lines 62–65: return { payload: Buffer.from(signedMessageBuffer).toString('base64'), attestationPayload: Buffer.from(signedMessageBuffer).toString('base64'), requestUniqueId, attestation }. Both fields hold identical base64-encoded data; one is redundant. Cross-axis with workflowClient.ts:537–555 submitToStore which probes attestation.payload OR attestation.attestationPayload OR input.attestationPayload as a three-step fallback — the dual-field surfaces churn between API contract revisions. Receiver-side code must understand whichever field is populated.
Pick one field name (recommend payload per simpler shape); deprecate the other with @deprecated JSDoc comment + migration path documented; coordinate with portal server-side to migrate at next contract version. The three-step fallback at workflowClient should be reducible to a single-field-read after migration.
ensurePublicationSignerBalance continues publication with a WARNING when balance check fails, allowing wasted upload + signing-time failuredapp-publishing/packages/cli/src/publication/fundingPreflight.ts lines 113–122publication/fundingPreflight.ts lines 113–122: if balance fetch throws (RPC timeout, RPC error response, RPC-side rate limit), the function returns buildRpcWarningMessage(...) instead of throwing — caller at CliSetup.ts:159–161 surfaces the warning and continues into APK upload + workflow. Publisher uploads multi-MB APK + waits for ingestion + reaches the signing step → signing fails with "Account has insufficient funds" → publisher must restart from scratch having spent bandwidth + minutes. The funding preflight's purpose is explicitly to "fail fast before uploading the APK" (lines 11–12 inline comment) — continuing on RPC failure inverts this design goal.
Distinguish balance-confirmed-low (throw) vs balance-unknown-due-to-RPC-error (operator decision). Add --require-balance-check flag that promotes RPC errors to fatal; default to current warning-then-continue behavior for backward compatibility. Alternatively: retry RPC fetch 2-3 times with backoff before degrading to warning (network blips shouldn't immediately degrade).
MobileWalletAdapterClient.signAndSendTransactions error message is the EXACT INVERSE of sibling signTransactionsmobile-wallet-adapter/android/clientlib/src/main/java/.../protocol/MobileWalletAdapterClient.java line 1082 vs line 853Line 1082: throw new IllegalArgumentException("transactions must be null or empty") — message says transactions MUST be null/empty. The check (lines 1080–1084) is if (t == null || t.length == 0) throw ... — fires when t IS null or empty. The correct message is "transactions must NOT be null or empty" (note "NOT"). Sibling at line 853 signTransactions has identical check with correct message: "transactions must not be null or empty". Asymmetric error-message correctness between sibling-methods that share the same precondition. Consumer dApp seeing the message at line 1082 is misled about which condition caused the throw.
Fix line 1082 to "transactions must not be null or empty" (insert "not"). Add lint rule scoped to sibling-method pairs sharing the same precondition: error messages should match.
MobileWalletAdapterClient.java:40–42 assumes Solana-only signature length; cross-axis recurrence with V3 B39 walletlib (#44): support multiple addresses TODOmobile-wallet-adapter/android/clientlib/src/main/java/.../protocol/MobileWalletAdapterClient.java lines 40–42Line 40–42: // TODO: this assumes Solana-length signatures. Revisit this assumption when adding support for alternative chains. plus private static final int OFFCHAIN_MESSAGE_SIGNATURE_LENGTH = 64; at line 42. Vendor's reference SDKs (both clientlib + walletlib) carry chain-specific assumption-TODOs at production stable releases.
Either (a) document at MWA spec that current reference impl is Solana-only (positive-form); (b) close the TODOs with explicit migration plan + tracking issue; (c) refactor signature handling into chain-pluggable abstraction (matches the spec's multi-chain framing). Cross-SDK coordination required.
JsonRpc20Client error messages for reserved-method-name detection lack closing parenthesismobile-wallet-adapter/android/clientlib/src/main/java/.../protocol/JsonRpc20Client.java lines 49–50 + 97–98Lines 49–50: "reserved method name (starts with 'rpc.'" — open-paren after "name", no close-paren. Same defect at line 98 in notification(): "reserved notification name (starts with 'rpc.'". Java string literal is technically valid; the message displayed to consumer is grammatically malformed. Pedantic; low impact.
Append ) to both messages: "reserved method name (starts with 'rpc.')" and "reserved notification name (starts with 'rpc.')".
MobileWalletAdapterSession.parseHelloReq throws UnsupportedOperationException for crypto-class failuresmobile-wallet-adapter/android/clientlib/src/main/java/.../protocol/MobileWalletAdapterSession.java lines 101–110Lines 111–112: try { ... signature crypto ... } catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException e) { throw new UnsupportedOperationException("Failed signing HELLO_REQ public key payload"); } and similarly at line 109 for the DER→P1363 conversion. UnsupportedOperationException is semantically incorrect for these failure modes: NoSuchAlgorithmException is environment-misconfiguration (algorithm provider missing), SignatureException is runtime crypto failure (key tampering / algorithm-state corruption), InvalidKeyException is key-shape failure. None of these are "operation not supported" — the operation IS supported but failed for diverse reasons. Generic UnsupportedOperationException flattens the diagnostic.
Throw more specific exception: IllegalStateException for missing algorithm provider; SecurityException for runtime crypto failure; or wrap in SessionMessageException (the class is already defined at the parent + used at parseHelloRsp line 148). Preserves the original throwable as cause so consumer debugging can trace.
publish/Publish* legacy surface (PublishCoreSubmit / PublishCoreUpdate / PublishCoreRemove / PublishCoreSupport) are SILENT no-ops with Promise<never> return type but actually return undefined as neverdapp-publishing/packages/core/src/publish/{PublishCoreSubmit,PublishCoreUpdate,PublishCoreRemove,PublishCoreSupport}.ts (4 sites) + dapp-publishing/packages/core/src/portal/compat.ts:1-6Each function takes typed PublishXxxInput, emits a single console.warn via deprecateLegacyPublishSurface, and returns undefined as never. The TypeScript signature Promise<never> semantically means the promise NEVER resolves successfully (typically only rejects or never settles); returning undefined breaks the type contract. Consumer impact: a publisher invoking await publishSubmit(network, input, dryRun) sees a console.warn (typically not surfaced in CI / automation / silent-runner contexts) but the call resolves successfully with undefined. The publication does NOT happen, the publisher's downstream code "succeeds" silently, and no error is raised. The console.warn channel is unreliable for behavior-changing deprecations — warnings get suppressed in many runners and IDEs. The deprecateLegacyPublishSurface helper at portal/compat.ts:1-6 is the only signal.
Two-step: (a) immediate fix — change Promise<never> return to Promise<void> or Promise<{ deprecated: true, replacement: string }> and have the function throw new Error("publishSubmit is deprecated; use createPublicationWorkflow(...).startPublication(...) from @solana-mobile/dapp-store-publishing-tools"). Throwing converts silent-failure to fail-loud. (b) longer-term: remove these surfaces entirely in next major version; the typed input objects keep deprecation TODOs visible to consumers via TypeScript ts(2769) errors when calling the removed function. NEVER ship a function whose runtime behavior is "do nothing" while the type signature claims Promise<never>.
AnchorProvider commitment: "processed" for wallet-signed transactions — documented "Blockhash not found" preflight reject traptutorial-apps/AnchorCounterDapp/src/components/counter/counter-data-access.tsx lines 35-38 + ConnectionProvider.tsx:18 default configtutorial-apps/AnchorCounterDapp/src/components/counter/counter-data-access.tsx:35-38 constructs new AnchorProvider(connection, anchorWallet, { preflightCommitment: "confirmed", commitment: "processed" }). The commitment: "processed" setting governs connection.getLatestBlockhash calls inside Anchor's tx-build path — Anchor will fetch a blockhash from a processed-commitment slot. The Trap: dApp uses one RPC (e.g. its own connection endpoint) and the wallet uses a DIFFERENT RPC (e.g. api.mainnet-beta.solana.com). Processed-commitment blockhashes have very short propagation latency across the Solana validator network; a processed-but-not-finalized blockhash from dApp RPC is virtually guaranteed to NOT be visible at wallet RPC at sign time, leading to BlockhashNotFound preflight reject. The tutorial teaches consumers the wrong pattern — wallet-signed transaction blockhashes MUST use commitment: "finalized" against an RPC that wallets also trust (api.mainnet-beta.solana.com). New developers copy-pasting this tutorial inherit the trap; production apps surface intermittent "Transaction simulation failed: BlockhashNotFound" errors that defy easy reproduction.
(a) Tutorial-side fix: change AnchorProvider config to { preflightCommitment: "finalized", commitment: "finalized" } for wallet-signed-tx flows; document that the choice is mandatory in inline comment naming the BlockhashNotFound failure-mode. (b) Add tutorial-level README section: "Why commitment: 'finalized' for wallet-signed transactions" — explain dApp-RPC vs wallet-RPC blockhash-visibility lag. (c) Vendor-wide: audit other tutorial-apps + react-native-samples for the same defective default; fix as cluster.
useCallback hooks have wrong useMemo/useCallback deps arrays at multiple sitestutorial-apps/AnchorCounterDapp/src/components/sign-in/sign-in-ui.tsx lines 27, 59 + src/utils/useMobileWallet.tsx lines 28, 92-103Example sites: sign-in-ui.tsx:27 useCallback(handleConnectPress, [authorizationInProgress, authorizeSession]) — the callback at line 12 uses connect (line 18) and authorizationInProgress, but the deps array names authorizeSession (destructured at line 9 but unused in callback body) instead of connect. Identical defect at sign-in-ui.tsx:59 (signIn button — closure references signIn but deps name authorizeSession). Same class at useMobileWallet.tsx:28 (signIn callback uses authorizeSessionWithSignIn but deps array [authorizeSession]) and useMobileWallet.tsx:92-103 (final useMemo deps [signAndSendTransaction, signMessage, signTransactions] missing connect, connectAnd, signIn, disconnect). react-hooks/exhaustive-deps ESLint rule would flag every site. Tutorial-scaffold-fidelity axis: tutorial code teaches consumers via copy-paste; stale-closure defects propagate into production codebases.
(a) Fix the deps arrays in tutorial code; ensure react-hooks/exhaustive-deps ESLint rule is in the tutorial's eslint config + passes in CI. (b) Cross-tutorial sweep: audit all other tutorial-apps + react-native-samples for useCallback / useMemo deps-array correctness. (c) Add tutorial-level lint pass before merging tutorial contributions.
status field across sibling functions in same package — ingestion.ts checks both casings, session.ts checks only onedapp-publishing/packages/core/src/portal/workflow/ingestion.ts lines 14, 34-38, 103 vs state/session.ts lines 38, 42portal/workflow/ingestion.ts:14, 34-38, 103 exhaustively check both PascalCase + lowercase casings of "Ready" / "ready" / "Failed" / "failed". By contrast, portal/workflow/state/session.ts:38 checks only session.status === "failed" (lowercase only); :42 checks only session.status === "completed" (lowercase only). Defect class: if the portal returns a session-status with PascalCase casing at the session endpoint but lowercase at the ingestion endpoint (or vice versa as the portal's contract evolves), resolvePublicationSessionStage will silently fall through to default "PreparedForMint" instead of recognizing the actual stage. Inconsistent defensive-handling across sibling consumer-side files of the same API. Root-cause axis: the portal-side API contract is non-canonical at status field casing; the consumer's defense is partial.
Either (a) normalize casing at a single deserialization boundary (e.g. constructor / single normalizer) so the consumer code can rely on canonical casing throughout; OR (b) check both casings at every call site consistently. Preferred: (a) — single normalizer at client.getPublicationSession / client.getIngestionSession deserialization layer that lowercases all status-class fields. Long-term: coordinate with portal-side to canonicalize at API contract level.
client.getIngestionSession({ sessionId, ingestionSessionId }) 2-instance dual-shape redundancy at API contract — same value passed as two different parameter namesdapp-publishing/packages/core/src/portal/workflow/lifecycle.ts lines 353-356 + ingestion.ts lines 99-102 (2 sites)Both lifecycle.ts:353-356 and ingestion.ts:99-102 call client.getIngestionSession with { sessionId: createdIngestionSessionId, ingestionSessionId: createdIngestionSessionId } — passing the same string twice under different keys. The dual-key shape suggests the contract evolved between sessionId and ingestionSessionId and the consumer hedges. Defect-class: API contract has two fields meaning the same thing; consumer code keeps both populated; receiver-side code must handle whichever is populated. Wire-shape sprawl.
Coordinate with portal-side to pick one canonical field name; deprecate the other with @deprecated TypeScript marker + migration path; consolidate consumer calls to single-field after migration.
resolvePublicationSignerAddress 3-level fallback chain silently conflates publisher with collectionAuthority when earlier fields are undefineddapp-publishing/packages/core/src/portal/workflow/state/bundle.ts lines 118-126portal/workflow/state/bundle.ts:118-126 returns first-defined of dappWalletAddress ?? requiredSigner ?? collectionAuthority. collectionAuthority is typically a DIFFERENT wallet from the publisher (it's the original NFT collection authority that grants verify-collection-PDA write permission). If dappWalletAddress + requiredSigner are both undefined in a particular signerAuthority shape, the function returns the collection authority as if it were the publisher. Subsequent code at execution.ts:331-337 validates signer.publicKey !== requiredSignerAddress and throws — so the runtime path is loud — but the FALLBACK SEMANTICS are still suspect: a developer reading the code learns that a missing publisher field is "OK because we'll use the collection authority" which is a category error.
(a) Replace fallback chain with explicit null-checks + throw with diagnostic message: if (!bundle.signerAuthority.dappWalletAddress) throw new Error("Publication bundle is missing dappWalletAddress at signerAuthority") — fail-fast and loud rather than silent-cascade-then-late-validate. (b) Document the fallback semantics inline if intentional — clarify when requiredSigner and collectionAuthority are valid publisher candidates (operations like admin-deployment-of-collection where authorities coincide).
uploadLocalApkToPortal does NOT validate the uploadUrl returned by portal createUploadTarget is HTTPS — partial HTTPS-enforcement gap inherited from V4 §A32 / §B.Qdapp-publishing/packages/core/src/portal/workflow/source/preparation.ts lines 102-110 (uploadTarget.uploadUrl unchecked)HOWEVER preparation.ts:102-110 performs the actual APK upload via await fetch(uploadTarget.uploadUrl, ...) with NO scheme validation. The uploadUrl is portal-issued (typically a presigned S3 / GCS URL with a TTL-limited token in the query string). If a misconfigured portal returns http:// instead of https:// (dev environment leaked into production, regional endpoint typo, etc.), the APK + content-length + sha256 hash get sent over plaintext HTTP. Risk class: the presigned URL itself becomes capturable by a network observer within its TTL window, enabling MITM to PUT arbitrary bytes to that exact storage path. The APK build artifact is not particularly secret in itself, but the presigned-URL leak allows substitution attacks. Partial-enforcement axis: HTTPS enforced at primary portal endpoint, NOT at portal-issued secondary URLs.
Add scheme-check inline at line 102: if (!uploadTarget.uploadUrl.startsWith("https://")) throw new Error("Portal upload URL must use HTTPS scheme; received: " + uploadTarget.uploadUrl). Apply same gate to any other portal-issued secondary URLs (e.g. uploadTarget.publicUrl used as releaseFileUrl at line 130 — gets sent back to portal as the canonical file URL; if portal returns http there, downstream consumers reference http-URL forever). Cross-axis enforcement gate: "every portal-issued URL passed to fetch / stored to portal contract MUST be HTTPS unless dev-mode flag explicitly opts out".
publicationStageToCheckpoint case "Failed" falls through to default: return "created" — semantically misleading + likely dead branchdapp-publishing/packages/core/src/portal/workflow/state/checkpoints.ts lines 47-49portal/workflow/state/checkpoints.ts:47-49: case "Failed": default: return "created". The semantic interpretation is "a Failed stage = no checkpoint progress at all" — but in execution.ts:339-345 the Failed stage throws BEFORE reaching checkpoint resolution, so this branch never fires at the runtime path. Either the branch is dead code OR the semantic mapping is wrong (a Failed publication did make some progress — likely past created and possibly past mint-submitted — mapping to created discards diagnostic information).
(a) If branch is dead: delete the case "Failed": line so falling-through to default is implicit; document that Failed stage is caught upstream. (b) If branch is intended: return a more meaningful default (e.g. preserve session.checkpoint if present; only fall back to "created" if no checkpoint exists). Stack-trace-friendly diagnostic preservation.
getTokenMetadataCreateCollectionAddress deserialize-error catch-throws generic message — diagnostic loss at portal-tx validationdapp-publishing/packages/core/src/portal/signer.ts lines 151-163portal/signer.ts:151-163: try { const [decodedInstruction] = CreateStruct.deserialize(...) } catch (error) { throw new Error("Portal transaction contains an invalid token metadata create instruction."); }. The original error (Borsh deserialization detail — offset, field name, expected vs actual type) is dropped. Diagnostic loss class.
Preserve the original error as cause: throw new Error("Portal transaction contains an invalid token metadata create instruction.", { cause: error }). Modern Node Error.cause chains support — error stacks show the underlying Borsh failure.
resolvePublicationSessionStage Failed-state fallback chain breaks on empty-string lastErrordapp-publishing/packages/core/src/portal/workflow/execution.ts lines 339-345execution.ts:339-345: throw new Error(normalizedSession.lastError || normalizedSession.error || "Publication session failed"). If lastError is the empty string "" (a legitimate "no specific error message" value the portal might emit), || falls through to error; if error is also empty string, falls through to the generic default. Defect class: distinguishes "no error" from "empty error" only at the truthiness layer, which collapses both to the same fallback path. If lastError IS set to empty string deliberately (e.g. portal signals "failed but no message"), the consumer sees the generic "Publication session failed" message rather than realizing the portal returned an empty diagnostic. Inconsistent fallback-discipline within same package.
Use nullish-coalescing ?? instead of logical-or || at fields where empty-string is a meaningful value: normalizedSession.lastError ?? normalizedSession.error ?? "Publication session failed". Audit all fallback chains in package for || vs ?? consistency (some sites correctly use ?? per bundle.ts; the execution.ts site is the exception).
config={{commitment: 'processed'}} at app-level ConnectionProvider recurs across 4 OFFICIAL vendor tutorials — V5 B59 blockhash trap pattern cross-tutorial 5-instancetutorial-apps/{first-mobile-dapp/App.tsx:16, SimpleStorageDapp/App.tsx:16, MobileNFTMinter/App.tsx:19, SolanaReactNativeTutorial/SolanaReactNativeTutorialComplete/App.tsx:14} (4 sites) + V5 AnchorCounterDapp (5th site)The shared connection object then propagates the processed commitment to every connection.getLatestBlockhash() call at downstream transaction-building sites (e.g., SimpleStorageDapp/components/SignTransactionButton.tsx:25, SolanaReactNativeTutorial/.../RecordMessageButton.tsx:35 + :58). 5-tutorial-instance recurrence at SAME vendor within their officially published learning corpus — consumers copy-pasting any of these tutorials inherit the trap. Same vendor's ConnectionProvider.tsx SDK-level default is commitment: 'confirmed' (correct) — tutorials OVERRIDE the correct default with the trap.
Vendor-wide cross-tutorial sweep: change all config={{commitment: 'processed'}} defaults to 'confirmed' at app-level ConnectionProvider. Update inline comments naming the BlockhashNotFound failure-mode. Add an in-line block at each tutorial README explaining "Why 'confirmed' for wallet-signed transaction flows." Cross-tutorial coherence: vendor MAY add a lint rule / pre-merge gate in the tutorial-repo CI that flags commitment: 'processed' at any Connection/ConnectionProvider site adjacent to wallet.signTransactions paths.
authToken Bearer-equivalent) console-logged in 3 official tutorials' AuthorizationProvider scaffold + AsyncStorage-persistence in 2 of them creates dual-surface credential leaktutorial-apps/{first-mobile-dapp/components/AuthorizationProvider.tsx:121, SimpleStorageDapp/components/providers/AuthorizationProvider.tsx:118-120 + :135, FarmingIdleGame/hooks/AuthorizationProvider.tsx:138} (4 auth-log sites) + tutorial-apps/{first-mobile-dapp,SimpleStorageDapp}/components/SignTransactionButton.tsx (2 tx-blob-log sites)first-mobile-dapp/components/AuthorizationProvider.tsx:121 console.log(authorizationResult) logs the full AuthorizationResult object (containing the auth token + account addresses + base64 keys). SimpleStorageDapp/components/providers/AuthorizationProvider.tsx:118-120 console.log('Retrieving auth ' + JSON.parse(cacheFetchResult, cacheReviver)) logs the FULL authorization object including authToken (Bearer-equivalent credential). SimpleStorageDapp/.../AuthorizationProvider.tsx:135 console.log('Caching auth: ' + JSON.stringify(authorization)) logs every auth-state transition with the token. FarmingIdleGame/hooks/AuthorizationProvider.tsx:138 same pattern — console.log('Caching auth: ' + JSON.stringify(auth)) (FarmingIdleGame additionally cleans address-only logging at :123-125 — partial discipline). Dual-surface leak class: logcat persists on-device + AsyncStorage stores cleartext on-device. Combined: an Android app crash report aggregator (Sentry / Firebase Crashlytics) sampling logcat could exfiltrate the authToken; a backup file dump or device-image-class attack exposes the AsyncStorage plaintext. Cross-tutorial recurrence: 3-of-5 audited tutorials at this vendor exhibit the credential-log defect. Consumers copy-pasting any tutorial AuthorizationProvider scaffold inherit the credential-leak. SimpleStorageDapp/components/SignTransactionButton.tsx:66 + first-mobile-dapp/components/SignTransactionButton.tsx:78 also console.log(fromUint8Array(signedTransaction.serialize())) — leaking the SERIALIZED SIGNED TRANSACTION (containing the user's signature on a real transaction) to console.log.
(a) Immediate: replace every console.log of sensitive payload with explicit redaction (e.g., console.log('Auth received for: ' + nextAuthorization.selectedAccount.address.slice(0,8) + '…') — log only an opaque session identifier + truncated public address; NEVER the authToken or signature). (c) Vendor-wide tutorial sweep: audit other tutorial-apps + react-native-samples for parallel patterns; fix as cluster.
AsyncStorage.setItem / AsyncStorage.removeItem inside try/catch but NOT awaited — Promise rejection escapes try/catch (unhandled-rejection class)tutorial-apps/{SimpleStorageDapp/components/providers/AuthorizationProvider.tsx:136, FarmingIdleGame/hooks/AuthorizationProvider.tsx:139, FarmingIdleGame/hooks/AuthorizationProvider.tsx:148}SimpleStorageDapp/components/providers/AuthorizationProvider.tsx:136 calls AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(authorization)) inside a try block but without await — the IIFE's try/catch at :131-141 wraps a synchronous statement that immediately returns the Promise; if the Promise later rejects (storage full, disk error, RN bridge crash), the rejection propagates outside the catch and becomes an unhandled-rejection warning. FarmingIdleGame/hooks/AuthorizationProvider.tsx:139 (AsyncStorage.setItem) + :148 (AsyncStorage.removeItem) exhibit the identical defect. Defect class: the try/catch surface here is dead — error handling is intended at the surface but the missing await neutralizes it.
Add await before every AsyncStorage.* call inside try/catch blocks. ESLint rule @typescript-eslint/no-floating-promises would flag every site automatically; verify the rule is enabled in tutorial-repo .eslintrc.* and passes in CI. Cross-tutorial sweep at vendor level.
new Connection(clusterApiUrl('devnet'), 'confirmed') inside transaction-button callback BYPASSES app-level ConnectionProvider config — architecture-coherence break at scaffold layertutorial-apps/first-mobile-dapp/components/SignTransactionButton.tsx:28 (and likely other Button.tsx siblings — SendMemoButton, RequestAirdropButton etc. — same scaffold class)first-mobile-dapp/components/SignTransactionButton.tsx:28 constructs a fresh Connection inline at the button callback — hard-coding both cluster ('devnet') AND commitment ('confirmed') — while App.tsx:11 declares APP_CLUSTER = 'testnet' and App.tsx:16 passes config={{commitment: 'processed'}} to ConnectionProvider. Three layers, three different settings. Defect class: the central ConnectionProvider exists but the consumer-facing button doesn't use it — useConnection() is the prescribed access, but this site reaches around the context API to construct its own Connection with its own settings. A vendor design intent that consumers wire up centrally is silently undermined at the demonstration code. Cross-tutorial contrast: SimpleStorageDapp/SignTransactionButton.tsx:15 + :25 DO use useConnection() (correct pattern). Discipline split within the same vendor's tutorial-set.
Refactor first-mobile-dapp SignTransactionButton (and sibling Button components) to consume useConnection() from app-level context, matching the SimpleStorageDapp pattern. Cross-tutorial discipline: the canonical pattern is "one ConnectionProvider per app, consumers use useConnection()" — vendor MUST converge tutorial scaffolds to this pattern. Tutorial-level lint config — eslint-no-restricted-imports blocking direct Connection constructor calls in component code (allowed only in providers/ subfolder).
tutorial-apps/{SimpleStorageDapp/components/providers/ConnectionProvider.tsx:10, MobileNFTMinter/components/providers/ConnectionProvider.tsx:10, SolanaReactNativeTutorial/SolanaReactNativeTutorialComplete/App.tsx:9} (3 sites)SimpleStorageDapp/components/providers/ConnectionProvider.tsx:10 + MobileNFTMinter/components/providers/ConnectionProvider.tsx:10 declare export const RPC_ENDPOINT = 'devnet' — the name suggests an RPC URL (e.g. https://api.devnet.solana.com) but the value is a cluster identifier. SolanaReactNativeTutorial/SolanaReactNativeTutorialComplete/App.tsx:9 declares const DEVNET_ENDPOINT = clusterApiUrl('testnet') — name says DEVNET but value is testnet. Defect class: misleading scaffold constants teach consumers wrong vocabulary; downstream confusion when consumer attempts to substitute a real RPC URL for RPC_ENDPOINT (would crash, since clusterApiUrl(url) only accepts cluster identifiers). The downstream AuthorizationProvider passes cluster: RPC_ENDPOINT to wallet.authorize() — accidentally correct because RPC_ENDPOINT is actually a cluster id, but only by coincidence of naming abuse.
Rename RPC_ENDPOINT = 'devnet' → CLUSTER = 'devnet' (or CLUSTER_ID). Rename DEVNET_ENDPOINT = clusterApiUrl('testnet') → RPC_ENDPOINT = clusterApiUrl('testnet') AND fix the value to actual devnet (or rename the constant to match the testnet value). Vendor-wide tutorial scaffold sweep: naming = identity.
await setAuthorization(nextAuthorization) — cargo-cult await on a React useState setter that returns void — 4-site recurrencetutorial-apps/{first-mobile-dapp,SimpleStorageDapp,MobileNFTMinter}/components/.../AuthorizationProvider.tsx + AnchorCounterDapp/src/utils/useAuthorization.tsx} (4 sites) | **LOW** | Remove the cargo-cult await — change to setAuthorization(nextAuthorization); plain statement. Add an ESLint rule (@typescript-eslint/await-thenable) that flags await` of non-Promise values. Cross-tutorial sweep at vendor level.first-mobile-dapp/components/AuthorizationProvider.tsx:105, SimpleStorageDapp/.../AuthorizationProvider.tsx:152, MobileNFTMinter/.../AuthorizationProvider.tsx:107, and AnchorCounterDapp/src/utils/useAuthorization.tsx:123 all contain the identical statement await setAuthorization(nextAuthorization) inside handleAuthorizationResult. React useState setters are synchronous ((value: T) => void); awaiting undefined is a no-op runtime, but type-misleading: tutorial consumers reading this code learn that setAuthorization is async (it isn't) and develop wrong mental model about React state-management semantics. Cross-tutorial recurrence at SAME vendor — strongly suggests scaffold copy-paste origin without per-tutorial review.
ReflectorWebSocket.java StateCallbacks.onReflectionEstablishedmobile-wallet-adapter/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/transport/websockets/ReflectorWebSocket.java:243-244ReflectorWebSocket.java:243-244 documents onReflectionEstablished as "Invoked when this WebSocket fails attempting to connect to the server" — clearly copy-pasted from sibling onConnectionFailed Javadoc at line 241. The method actually fires on SUCCESSFUL reflection-handshake establishment per doReflectionEstablished at line 228 + the State transition at line 230 → REFLECTION_ESTABLISHED.
Replace lines 243-244 Javadoc with accurate description: /** Invoked when this WebSocket successfully completes the reflection handshake with the server */. Vendor-wide doc sweep for sister-method copy-paste defects (audit all StateCallbacks-class interfaces in walletlib for parallel Javadoc errors).
InterruptedException swallowed without restoring thread-interrupt status — Java best-practice violation at LocalWebSocketServer.java:74-75mobile-wallet-adapter/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/transport/websockets/server/LocalWebSocketServer.java:74-75The close() method calls stop(CLOSE_TIME_MS, ...) inside a try { ... } catch (InterruptedException ignored) {} block. When an InterruptedException is caught, Java best-practice REQUIRES Thread.currentThread().interrupt() to preserve the interrupt status for upstream code that needs to detect cancellation. The current code silently consumes the interrupt — downstream code calling LocalWebSocketServer.close() from an interruptible thread will lose its interrupt-state and continue running blindly.
Restore interrupt status in the catch block: } catch (InterruptedException e) { Thread.currentThread().interrupt(); Log.w(TAG, "Interrupted while stopping WebSocket server"); }. Vendor-wide sweep for catch (InterruptedException ignored) pattern across walletlib + clientlib Java. (Search regex: catch\s*\(\s*InterruptedException\s+\w+\s*\)\s*\{\s*\} — IntelliJ inspection "Interrupted exception is swallowed" catches this automatically).
FarmingIdleGame/utils/programUtils.tsx burner-wallet transaction path skips preflight + confirms only to processed-commitment — fragile-default tutorial patterntutorial-apps/FarmingIdleGame/utils/programUtils.tsx:331-333 + :335-342 (2 sites in same function sendAndConfirmSignedTransaction)programUtils.tsx:331-333 connection.sendRawTransaction(rawTransaction, {skipPreflight: true}) — skipping preflight on a real-money-class transaction (the path that signs with both owner wallet + burner keypair). Skipping preflight saves a few hundred ms of latency but means client-side errors (insufficient funds, account-not-found, program-error) aren't caught before submission → wasted SOL fees on failures. programUtils.tsx:335-342 connection.confirmTransaction({...}, 'processed') — confirming only to processed-commitment. A processed-commitment confirmation can ROLL BACK at the same slot if the validator network reorgs the leader slot. For an idle game with low blast radius this is OK; for a tutorial scaffold consumers might generalize to value-bearing apps, this is fragile.
(a) Default { skipPreflight: false } (omit the option) — let preflight catch client-side errors. (b) Default confirmation commitment to 'confirmed' (or 'finalized' for value-bearing tx). (c) Tutorial-level annotation: clearly mark fragile defaults as such — // skipPreflight: true OK only for low-stakes idle-game tx; PRODUCTION value-bearing tx MUST set skipPreflight: false. (d) Cross-tutorial sweep at vendor level.
FarmingIdleGame/utils/programUtils.tsx:156 getWithdrawIx transfers lamports: playerBalance — rent-exempt reserve protection breaks the transfer at runtimetutorial-apps/FarmingIdleGame/utils/programUtils.tsx:150-162programUtils.tsx:150-162 (getWithdrawIx) computes const playerBalance = await program.provider.connection.getBalance(player) then attempts SystemProgram.transfer({fromPubkey: player, toPubkey: owner, lamports: playerBalance}) — transferring the ENTIRE account balance including the rent-exempt reserve. Solana runtime rejects any transfer that would drop a non-system-owned account below its rent-exempt minimum. The transaction WILL fail at runtime. Stale-code clue: the inline comment at :160 says // 5000 for fee — suggesting the author intended lamports: playerBalance - 5000 (subtract tx fee) but forgot to update the actual code. Stale-comment + stale-code combination.
Subtract both the tx fee AND the rent-exempt minimum: const rentExempt = await connection.getMinimumBalanceForRentExemption(0); const fee = 5000; SystemProgram.transfer({fromPubkey: player, toPubkey: owner, lamports: playerBalance - rentExempt - fee}); Update the comment to match. Vendor-level reviewer-side: tutorial code that demonstrates value-transfer mechanics MUST be runtime-verified before shipping; the broken withdraw teaches consumers wrong patterns.
assert(mState != State.NOT_CONNECTED) at ReflectorWebSocket.java:146 — 3rd Java-site in same vendor for the V4 §A30 / V5 §A37 PRODUCTION-ASSERTION-AVOIDANCE patternmobile-wallet-adapter/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/transport/websockets/ReflectorWebSocket.java:146The onError callback contains assert(mState != State.NOT_CONNECTED); inside the synchronized block. Java assert statements are stripped at runtime when not run with -ea (assertion-enabled JVM flag — typically OFF on Android production builds).
Replace assert(mState != State.NOT_CONNECTED); with explicit runtime check: if (mState == State.NOT_CONNECTED) { Log.e(TAG, "onError fired in NOT_CONNECTED state — likely race"); return; }. Vendor-wide Java sweep for assert( pattern; replace with explicit runtime guards.
MobileWalletAdapterWebSocket.onClose NPE risk — ws.messageReceiver.receiverDisconnected() called without null-guard; could fire double-close or post-error-close pathmobile-wallet-adapter/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/transport/websockets/server/LocalWebSocketServer.java:97-99LocalWebSocketServer.java:97-99 calls ws.messageReceiver.receiverDisconnected() then sets ws.messageReceiver = null. If the underlying WebSocket library fires onClose twice (some library implementations do at error paths) OR onClose fires AFTER onError already nulled the receiver, the second call NPEs at ws.messageReceiver.receiverDisconnected().
Add null-guard: if (ws.messageReceiver != null) { ws.messageReceiver.receiverDisconnected(); ws.messageReceiver = null; }. Or use a synchronized (ws) block to ensure idempotence at double-close paths. Audit other WebSocket-callback methods (onOpen, onMessage × 2 overloads, onError) for parallel null-guard gaps.
Withdrawn by us (2)
- B3. Broken cross-link path
/android-native/*(legacy URL structure): Not a defect: the linked page loads; it is only missing from the sitemap and llms.txt, an index gap rather than a broken link. Withdrawn by us. - B4. Broken cross-link
/mobile-wallet-adapter/mobile-apps: Not a defect: the linked page loads; it is only missing from the sitemap and llms.txt, an index gap rather than a broken link. Withdrawn by us.