| Audited | anza-xyz/wallet-adapter at commit 3663416165b9a1af734c451e588b40cacd7091c1 |
|---|---|
| Date | 11 October 2026 |
| How it ran | API run on the Nacodex server, full audit, Standard review |
| Verdict after review | Pass with notes (rule: Fail if a High finding remains after review, otherwise Pass with notes) |
Each finding keeps the number it has in the audit report. The rating shown first is the one after review; the first automated rating is listed with it. 8 findings were first rated Medium or High; 2 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.
Medium after review (2)
From the report
const [publicKey, setPublicKey] = useState(() => adapter?.publicKey ?? null);
const [connected, setConnected] = useState(() => adapter?.connected ?? false);
connected and publicKey are read from the adapter only once, at mount. When the selected adapter changes, the listener effect (lines 122-160) resets them to disconnected and never reads the new adapter's real state. WalletProvider deliberately does not disconnect the mobile wallet adapter when the user switches to another wallet (lines 87-96). If the user then switches back, the context reports connected = false while adapter.connected is true. handleConnect returns early at line 255 (wallet?.adapter.connected), so the Connect button does nothing, and every sign call throws "not connected". The user is stuck until the page is reloaded.
At the start of the adapter effect, set the state from the adapter: setPublicKey(adapter.publicKey); setConnected(adapter.connected);. Do not let the cleanup's forced reset override that state.
Review: WalletProviderBase.tsx:44-45: Path traced end to end: 1. WalletProvider.tsx:93-95 skips adapter.disconnect() when the outgoing adapter is the mobile wallet adapter, so it stays connected underneath. 2. Adapter prop changes: effect cleanup at WalletProviderBase.tsx:158 calls handleDisconnect() which sets connected=false, publicKey=null (lines 137-142). The new effect (122-160) only subscribes; it never reads adapter.connected. 3. Switching back to MWA: context says connected=false. handleConnect returns at line 255 because wallet?.adapter.connected is true. The auto-connect effect (171-195) calls onAutoConnectRequest, which calls adapter.autoConnect()/connect(). 4. I read the MWA package source (@solana-mobile/wallet-adapter-mobile@2.2.0, lib/esm/index.browser.js:370-374): #connect begins if (this.connecting || this.connected) { return; }, so no connect event is emitted. The provider never learns the real state. Result: Connect does nothing and sendTransaction/sign throw WalletNotConnectedError until reload (reload restores via cached authorization). Trigger needs a mobile-web session with MWA connected, then selecting another listed wallet and returning. Narrow, hence the low end of MEDIUM. The report's description is accurate. No match found.
Upstream: No matching upstream report found (11 October 2026).
From the report
publicKey = await getPublicKey(transport, this._derivationPath);
} catch (error: any) {
throw new WalletPublicKeyError(error?.message, error);
The HID transport opened at line 81 is neither saved nor closed when reading the public key fails. A very common cause is the Solana app not being open on the device. disconnect() cannot reach the transport, and the device stays claimed by the page, so the user's retry can fail until they reload. Trezor (packages/wallets/trezor/src/adapter.ts:96-122) has the same problem: wallet.init() succeeds, but dispose() is never called when solanaGetPublicKey fails, so a retry can trip over the already-initialised connector.
In the failure paths, release the handle before rethrowing: await transport.close().catch(() => {}) for Ledger and await wallet.dispose() for Trezor. A try/finally that releases the handle unless connect succeeded also works.
Review: Ledger ledger/src/adapter.ts:81,88-90: transport = await TransportWebHIDClass.create(); then publicKey = await getPublicKey(transport, ...) in a try whose catch throws WalletPublicKeyError without transport.close(); the transport is a local (assigned to this._transport only at line 95), so disconnect() cannot reach it. Real leak. TransportWebHID.open (hw-transport-webhid 6.30.2) does await device.open() and registers listeners on getHID(); whether a retry then fails or double-registers I could not verify, so the Ledger impact is "device kept claimed / leaked handlers". Trezor trezor/src/adapter.ts:96-122: wallet.init(...) succeeds, then solanaGetPublicKey fails or returns !result.success (e.g. user cancels) and the catch throws WalletAccountError with no dispose(). this._wallet is only set at line 133, so disconnect() (line 145, if (wallet)) does nothing, and the provider's handleConnectError -> changeWallet(null) -> adapter.disconnect() is a no-op. Retry runs init again. In @trezor/connect-web@9.6.0 lib/impl/core-in-iframe.js:109-111: async init(settings) { if (iframe.instance) { throw ERRORS.TypedError('Init_AlreadyInitialized'); } .... So after one failed/cancelled connect, every retry fails with WalletConfigError until the page reloads. Firm. Both are shipped adapters. MEDIUM is fair (Trezor strongest).
Upstream: No matching upstream report found (11 October 2026).
Low after review (15)
From the report
await connection.confirmTransaction({ blockhash, lastValidBlockHeight, signature });
notify('success', 'Transaction successful!', signature);
confirmTransaction does not throw when a transaction lands but fails. It resolves with value.err set. The example ignores the result, so a failed transaction is shown as a success. The example exists to be copied, and integrators who copy it will show users false confirmations of payments or actions. The same pattern is in SendLegacyTransaction.tsx, SendV0Transaction.tsx and RequestAirdrop.tsx.
Keep the result: const { value } = await connection.confirmTransaction(...). If value.err is set, throw, or show an error, before announcing success.
Review: SendTransaction.tsx:37-38: The defect is real: the result is discarded. Same discard in SendLegacyTransaction.tsx:43, SendV0Transaction.tsx:56, RequestAirdrop.tsx:21 (checked with grep; all four sites). Not a signing path and not shipped: it is the example app, and the only harm is a wrong toast in a demo. Corroboration that maintainers treat it as a bug: in repo 08 the equivalent example reads const {value: status} = await connection.confirmTransaction({...}); if (status.err) throw .... MEDIUM is too high for demo code; reviewed LOW (copy-paste risk). No match found.
Upstream: No matching upstream report found (11 October 2026).
From the report
return (await wallet.signTransaction(transaction)) || transaction;
If the wallet resolves with no value, the caller gets back the transaction it passed in, which may be unsigned. The failure then shows up much later as a confusing RPC signature error instead of a sign error. This fallback exists for wallets that sign in place, but it does not check that a signature was actually added. The same fallback is in about 25 adapters, including signAllTransactions (for example solflare, coinbase, torus, trust, mathwallet).
If the wallet returns nothing, check that the expected signer's signature is present on the input, and throw WalletSignTransactionError if it is not. Put this check in one shared helper in the base package.
Review: phantom/src/adapter.ts:234: return (await wallet.signTransaction(transaction)) || transaction; Pattern grep: 47 lines of || transaction / || transactions across packages/wallets/*/adapter.ts. Exact path: a wallet resolving with a falsy value makes the adapter return the unsigned input. Consequence is a later RPC signature failure, not an unauthorised signature or lost funds. It exists to support wallets that sign in place. LOW is fair.
packages/core/base/src/adapter.ts:141From the report
const interval =
// TODO: #334 Replace with idle callback strategy.
setInterval(detectAndDispose, 1000);
scopePollingDetectionStrategy stops the timer only when the wallet is detected, and it returns no handle. For each wallet the user does not have, the interval runs for the life of the page and keeps the adapter instance alive. An app that lists many adapters runs that many timers. An app that builds adapters more than once, for example per render or when a memo recomputes, leaks one more timer per adapter each time.
Return a dispose function and expose it, for example as destroy(), on adapters. Cap the polling with a timeout or backoff, or implement the idle-callback strategy the TODO mentions. Where wallets support wallet-standard registration events, use those instead.
Review: core/base/src/adapter.ts:141-144: setInterval(detectAndDispose, 1000) with // TODO: #334 ...; disposers run only when detect() returns true. Mechanics correctly described (used by 28 adapter files). But forever-polling is intentional: upstream PR/issue #327 "Repeat wallet detection polling periodically" says wallets should be detected live after the user installs or enables an extension without refreshing. Cost is one cheap property check per second per uninstalled wallet; the "leak" claim for adapters rebuilt per render is a consumer misuse. The TODO issue #334 (idle callback) is open, so the cost is already known. Fair LOW.
accountChanged listener attached after a wallet-initiated disconnectFrom the report
wallet.off('disconnect', this._disconnected);
connect() attaches both disconnect and accountChanged (lines 148-149), and creates a new SDK instance each time. disconnect() removes both listeners (lines 166-167), but _disconnected removes only the first. After the wallet disconnects on its own and the user reconnects, the orphaned old instance can still fire accountChanged. That overwrites _publicKey and emits connect with a key that does not belong to the current session, so the app shows one account while signing with another.
Add wallet.off('accountChanged', this._accountChanged); to _disconnected, as the Phantom adapter does.
Review: solflare/src/adapter.ts:265: _disconnected calls wallet.off('disconnect', ...) only. connect() adds both listeners (148-149), disconnect() removes both (166-167), Phantom's _disconnected removes both (verified at phantom adapter ~281). The asymmetry is real. The claimed consequence (old SDK instance fires accountChanged, overwriting _publicKey, "signing with another account") depends on the external Solflare SDK still emitting on a disconnected instance, which I could not show. Signing goes through this._wallet (the new instance), not the overwritten key. A listener-hygiene bug with an unproven impact: LOW.
Upstream: No matching upstream report found (11 October 2026).
From the report
if (!getIsMobile(adaptersWithStandardAdapters)) {
getIsMobile reads each adapter's readyState, which changes later through detection. The memo at line 72 and the effect at line 146 depend only on the adapter array, so a readiness change after the first render does not re-evaluate the environment. Today this is mostly hidden: in-app browsers are WebViews, and those are treated as desktop anyway. It becomes a real defect as soon as detection timing changes.
Derive the environment from the wallets state, which tracks readyState, or include the readiness values in the dependency list.
Review: WalletProvider.tsx:54 calls getIsMobile(adaptersWithStandardAdapters) inside useMemo (53-72) keyed on the adapter array and endpoint. getEnvironment (core/react/src/getEnvironment.ts:20-27) reads adapter.readyState === Installed for non-MWA adapters, which changes after detection without changing the array identity. Real staleness; effect narrow (Android non-WebView browsers where an extension adapter later becomes Installed). LOW.
From the report
isConnectingRef.current = false;
setConnecting(false);
The ref and the state are written together at six places (lines 127, 139, 181, 191, 260, 268), sometimes in opposite order, and isDisconnectingRef/setDisconnecting follow the same pattern. A future edit that updates one copy and misses the other will produce a provider that refuses to connect or shows a spinner forever.
Add one small setter, for example setConnectingState(value), that updates both copies, and use it at every site. Do the same for disconnecting.
Review: Pairs verified at WalletProviderBase.tsx 127-128, 138-139, 181-182, 191-192 (opposite order), 260-261, 268-269 (+ disconnecting pairs). Accurate; no current bug.
From the report
const state = useState<T>(() => {
The state is read from storage only once. If the localStorageKey prop changes, the effect at line 52 writes the previous key's in-memory value under the new key and overwrites whatever was stored there.
Re-read storage when key changes, for example by tracking the last key in a ref and reinitialising. Alternatively, document that the key must not change, or key the provider so it remounts.
Review: useLocalStorage.ts:15 reads once in the useState initialiser; the effect at 33-52 depends on [value, key] and writes value under the new key. Accurate; needs a changing localStorageKey prop, which is rare.
connect() while one is in flight resolves at once, before the wallet is connectedFrom the report
if (this.connected || this.connecting) return;
A caller that awaits connect() during an in-flight connect continues immediately, while publicKey is still null. The same early return is in about 34 adapters, and in the provider (packages/core/react/src/WalletProviderBase.tsx:255 for connect, :274 for disconnect).
Keep the pending promise, for example this._connectPromise ??= this._doConnect().finally(() => (this._connectPromise = undefined)), and return it to concurrent callers. This is best done once in the base adapter, with the same approach in the provider.
Review: phantom/src/adapter.ts:126 if (this.connected || this.connecting) return;. Count of files with this exact guard: 34 (matches the report). Provider guards at WalletProviderBase.tsx:255 and :274 verified.
From the report
signature = await sendTransaction(transaction, connection, { minContextSlot });
The buttons are disabled only when no wallet is connected. A double click starts two send or sign requests, which can produce two transactions. The same pattern is in SendLegacyTransaction, SendV0Transaction, SignTransaction and RequestAirdrop.
Track a busy flag, return early or disable the button while a request is pending, and clear the flag in finally.
Review: SendTransaction.tsx:34; button disabled={!publicKey} only (line 45); same in Legacy (55), V0 (68), SignTransaction (43), RequestAirdrop (29). Example code; wallets generally serialise popups.
From the report
if (wallet) {
...
this.emit('disconnect');
Every adapter re-implements the same connect, disconnect and sign wrappers: the error mapping, the emit-then-rethrow, the _connecting flag in finally, and listener wiring. The copies are no longer identical. Huobi, Trezor, TokenPocket and HyperPay emit disconnect only when a wallet is held, while Phantom, Ledger, Torus and Sky always emit it. Findings 2, 7 and 13 are all examples of a fix that has to be repeated in dozens of places.
Move the shared lifecycle into protected helpers or a template method on the base adapter, so each adapter supplies only its wallet-specific calls.
Review: huobi/src/adapter.ts:132-141: disconnect() emits disconnect only inside if (wallet). Same in tokenpocket (149-155) and hyperpay (136-142). Phantom (line 192), Ledger (122), Torus (145), Sky (140-153) emit unconditionally. Claim exactly right.
From the report
<h1 className="wallet-adapter-modal-title">Connect a wallet on Solana to continue</h1>
Apps cannot localise or reword the modal. The sibling BaseWalletMultiButton already accepts a labels prop, so the UI package handles text in two inconsistent ways.
Add a labels prop to WalletModal (title, "More/Less options", "Already have a wallet?") and use the current English text as the defaults.
Review: WalletModal.tsx:139 hard-coded <h1>; BaseWalletMultiButton.tsx:8,19,54 has a labels prop. Accurate.
From the report
if (typeof window !== 'undefined' && window.Buffer === undefined) {
(window as any).Buffer = Buffer;
}
Identical polyfills/Buffer.ts and polyfills/index.ts files are in coin98, keystone, ledger and trezor. Any change to the guard must be made four times, and the copies can drift.
Keep the polyfill once, in the base package or a small shared module, and import it from the four adapters.
Review: diff -q against ledger's file: identical in coin98, keystone, trezor. Accurate.
message handler acts on messages from any senderFrom the report
if (data && data.origin === 'mathwallet_internal' && data.type === 'lockStatusChanged' && !data.payload) {
this._disconnected();
The handler checks only fields inside the message body, which any sender controls. It does not check event.source or event.origin. Any embedded cross-origin frame, such as an ad or a third-party widget, can post this object to the parent window and force the user's wallet session to disconnect at will.
Before acting, require event.source === window and event.origin === window.location.origin.
Review: mathwallet/src/adapter.ts:160-165: handler tests data.origin === 'mathwallet_internal' && data.type === 'lockStatusChanged' && !data.payload and calls _disconnected(); no event.source/event.origin check. Accurate: any frame holding a reference to the window (parent, opener, iframe) can postMessage that object. Consequence: the adapter drops its local session state and emits disconnect; no signing, no funds, user re-connects. The attacker must already be able to run in or embed the page. Niche wallet. LOW. Upstream: mathwallet (54 hits; open #991 is "off is not a function", #710 "Cannot connect Math Wallet", neither about this), message event origin postMessage (0): no match found.
Upstream: No matching upstream report found (11 October 2026).
signMessage returns an un-awaited promise from inside try, so failures skip error handlingFrom the report
return keyring.signMessage(publicKey, message);
Without await, a rejected signing promise skips both catch blocks. The caller gets the raw, unwrapped error, and the adapter never emits error, so the provider's onError handler is never called. The catch at line 142 also uses the wrong error class (WalletSignTransactionError).
Use return await keyring.signMessage(publicKey, message);, and throw WalletSignMessageError in the catch.
Review: keystone/src/adapter.ts:139-143: try { return keyring.signMessage(publicKey, message); } catch (...) { throw new WalletSignTransactionError(...) }. Without await the rejection bypasses both catches: error is unwrapped, emit('error') not called, and the catch uses the wrong class. All three points verified. The caller still receives the rejection, so nothing is swallowed and nothing is signed wrongly. LOW (error-handling consistency).
Upstream: No matching upstream report found (11 October 2026).
ConnectionProvider default config creates a new Connection on every renderFrom the report
config = { commitment: 'confirmed' },
}) => {
const connection = useMemo(() => new Connection(endpoint, config), [endpoint, config]);
When config is omitted, which is the common case, the default object is new on each render, so the memo never hits. Every re-render of the provider's parent builds a new Connection and a new context value. All consumers that depend on connection re-render and re-run their effects: they refetch and re-subscribe.
Hoist the default into a module constant (const DEFAULT_CONFIG: ConnectionConfig = { commitment: 'confirmed' }) and memoise the context value.
Review: ConnectionProvider.tsx:14,16: config = { commitment: 'confirmed' } default and useMemo(() => new Connection(endpoint, config), [endpoint, config]). Accurate: a new object per render defeats the memo. But the context value { connection } is already a new object on every render, and the provider normally sits at the app root where re-renders are rare. Connection construction opens no socket. Real perf/hygiene issue, LOW.
Upstream: No matching upstream report found (11 October 2026).
Info after review (4)
From the report
const input: SolanaSignInInput = {
domain: window.location.host,
The example builds the sign-in input in the browser and verifies it in the browser, with no server-issued nonce, issuedAt or expiration. Developers who copy it get a sign-in whose signed message can be replayed. SignMessage.tsx uses a static message in the same way.
Have the server issue the input, with a nonce and an expiry. Verify the signature on the server and consume the nonce. At minimum, add a comment in the example saying this is a client-only demo.
Review: SignIn.tsx:17-18: const input: SolanaSignInInput = { domain: window.location.host, .... Input built and verified in the same browser (verifySignIn(input, output)), so there is nothing to replay against; it is a client-only demo. Fair INFO (a comment would do). Not shipped.
From the report
await wallet.connect();
} catch (error: any) {
throw new WalletConnectionError(error?.message, error);
Browser wallets refuse to connect, or fail silently, on non-localhost http: origins. The adapter forwards whatever opaque error the wallet raises, or none at all, so developers lose time diagnosing it. A search of all adapter sources for isSecureContext, location.protocol and http:// found no origin check.
Before calling wallet.connect(), check window.isSecureContext, or the protocol and host. If the origin is not secure, throw a WalletConnectionError that says an HTTPS origin is required.
Review: phantom/src/adapter.ts:146: await wallet.connect(); inside try, error mapped to WalletConnectionError. grep for isSecureContext / location.protocol across packages finds only WalletProvider.tsx:41 (builds the app identity URI) and one test. The absence is true. It is a developer-experience gap; the wallets themselves refuse insecure origins, so there is no security consequence. INFO.
From the report
if (value) return JSON.parse(value) as T;
The default keys walletName (WalletProvider.tsx:48) and autoConnect (example AutoConnectProvider.tsx:17) have no namespace. Any other script on the same origin can write them, and the parsed value is cast without a type check. A foreign value in walletName silently picks no wallet, or the wrong one.
Namespace the default keys, for example solana-wallet-adapter:walletName. Check the parsed type (a string or null for the wallet name, a boolean for auto-connect) and fall back to the default if it does not match.
Review: useLocalStorage.ts:21: if (value) return JSON.parse(value) as T;. Default key walletName (WalletProvider.tsx:48) and autoConnect (example AutoConnectProvider.tsx:17) are un-namespaced. But the parsed walletName is only compared with a.name === walletName (WalletProvider.tsx:81), so a bad value yields no wallet, not a wrong one that gets signing authority; any same-origin script already controls the page. Renaming a key that apps and docs rely on would be a breaking change. INFO.
From the report
console.error(constructMissingProviderErrorMessage('call', 'setVisible'));
useWallet.ts and useWalletModal.tsx each build their own message for this case. With only two copies this is acceptable. It becomes worth sharing if a third context needs the same message.
Nothing is needed now. If the pattern recurs, extract a shared helper.
Review: useWalletModal.tsx:10,16,21 own constructMissingProviderErrorMessage; useWallet.ts uses logMissingProviderError. Accurate; INFO is right.