| Audited | solana-foundation/solana-web3.js at commit 8837d96f7ce5b446c5f95f1a6dd58647b1eb02b1 |
|---|---|
| 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. 6 findings were first rated Medium or High; 4 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 (4)
From the report
this._disconnectChannel(
SUBSCRIPTION_CHANNEL_CLOSE_CODE_UNEXPECTED,
error instanceof Error ? error : new Error(String(error)),
When the channel cannot be created, _disconnectChannel calls onDisconnected. That marks every subscription pending and calls updateSubscriptions(), which calls ensureConnected() again straight away (controller.ts:178 and controller.ts:256-258). Nothing in this cycle waits. While the RPC endpoint is down or rate-limiting, every client with an active subscription retries in a tight loop. That adds load to the endpoint and writes a ws error: line to the log on each attempt.
Add capped exponential backoff with jitter before reconnecting after an unexpected close. Reset it after a successful connection.
Review: runtime.ts:421-431: Loop traced in code: _disconnectChannel (744-757) sets _channel = null, _channelAbortController = null, logs console.error('ws error:', ...), then this._callbacks.onDisconnected(code) -> controller.handleRuntimeDisconnected (code != 1000: marks every subscription pending, controller.ts:178 void this.updateSubscriptions()) -> updateSubscriptions (235): subscriptions still exist, channel === null -> subscriptionsRuntime.ensureConnected() (controller.ts:256-258) -> ensureConnected (runtime.ts:388) sees _channelAbortController === null and calls _createSubscriptionChannel immediately. No setTimeout, no counter, no cap anywhere in controller.ts, runtime.ts or registry.ts (grep setTimeout|backoff|retry|reconnect: only the idle-close timer at runtime.ts:668). Pace is bounded only by how fast the connect attempt fails (immediate on ECONNREFUSED/DNS failure; OS timeout when blackholed). Same path for the 'error' event at lines 406-418. Upstream: websocket reconnect (5 hits: #3864 closed fix for old rpc-websockets teardown retry, #2591, #1124, #1391, #1106), subscription reconnect backoff (#1124 closed). All concern the pre-rewrite rpc-websockets implementation; none addresses this runtime. No match found for this code path.
Upstream: No matching upstream report found (11 October 2026).
From the report
transaction.signatures.some(
keyObj => keyObj.publicKey.toString() === pubkey.toString(),
) || message.isAccountSigner(account),
Transaction.fromandMessage.fromaccept input of any length, even though a real transaction is at most 1,232 bytes.- For every account reference in every instruction,
populatescans the whole signature list and base58-encodes both keys again on each comparison. The work grows with the number of signatures multiplied by the number of account references. - A crafted oversized payload can block the event loop of a server that parses transactions received from users.
- Account indexes are never checked against
accountKeys.length. An out-of-range index produces an unhelpfulTypeErrorinstead of a clear parse error.
Reject input larger than the protocol packet size before decoding. Check that every account index and program index is within range. Build a set of signer keys once, or compare bytes with PublicKey.equals, instead of re-encoding inside the nested loop.
Review: legacy.ts:1021-1025 (inside populate, 998+): Traced: Transaction.from (972-986) decodes with TRANSACTION_WIRE_DECODER and Message.from (message/legacy.ts:219) with no length check (PACKET_DATA_SIZE is used only on serialize, line 935). populate runs the .some scan with two base58 toString() calls per comparison for every account reference of every instruction. Each signature needs a matching accountKeys[i] (else .toString() on undefined throws), so an attacker uses N signers and M non-signer references: cost ~ N*M base58 encodes, with input size ~ 96*N + M bytes. At 100 KB that is on the order of 500 signers x 50,000 references = 25 million comparisons (my estimate, not measured; I did not run anything), minutes of CPU in the event loop, versus a legitimate 1,232-byte transaction. Index-range claim also true: message.accountKeys[account] undefined -> pubkey.toString() TypeError (fails closed). Rating: real algorithmic-complexity DoS for services that parse untrusted raw transaction bytes (relayers, fee-payer services) without their own size cap; a caller-side cap neutralises it, and I believe (from memory of the v1.x sources, not verified here) this populate code is inherited from v1. MEDIUM at the low end. No funds or key exposure. Shipped.
Upstream: No matching upstream report found (11 October 2026).
From the report
(subscription.callbacks as Set<SubscriptionConfig['callback']>).delete(
callback,
);
Callbacks are stored in a Set (see addSubscriptionCallback, line 150). If the same function is registered twice for the same account, slot or logs subscription, the caller gets two subscription ids but the set holds only one entry. Removing either id deletes that entry. The other listener stops receiving notifications without any error, and the server subscription closes. An application that shares one handler across components loses updates without knowing it.
Store callbacks per client subscription id, for example in a Map<clientId, callback>, or keep a reference count per callback. Remove entries by id.
Review: registry.ts:417-419: addSubscriptionCallback (144, 150) stores callbacks in a Set keyed by subscription hash; createClientSubscription (~187) gives each registration its own client id but records only {callback, hash}; controller.removeClientSubscription (~213-232) calls removeSubscriptionCallback(hash, callback) and, if the Set is then empty, updateSubscriptions prunes and unsubscribes. Registering the same function reference twice for the same account/slot/logs subscription yields two ids, one Set entry; removing either id deletes the entry and the other listener stops receiving, with no error. Verified by code reading; I did not run it. Trigger needs the identical function object registered twice for identical parameters (e.g. a shared module-level handler). Silent loss of notifications in a financial library; LOW-to-MEDIUM, kept MEDIUM at the low end. I believe the v1.x Set design behaved the same (from memory, not verified here). Upstream: removeAccountChangeListener callback (#3053 off-topic), onAccountChange same callback (0): no match found.
Upstream: No matching upstream report found (11 October 2026).
From the report
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
This job has contents: write. If the action's owner retags v4, or the action is compromised, arbitrary code can run with a token that can push to the repository. dessant/lock-threads, dessant/label-actions and actions/stale hold issue and pull-request write permissions and are also referenced by tag. The publish workflow already pins its actions to commit SHAs.
Pin every third-party action to a full commit SHA, and update the pins with a tool such as Dependabot.
Review: The report's claims are exact. Real hygiene finding; MEDIUM is the conventional rating for an unpinned third-party action with write scope, but nothing here is shipped to users.
Upstream: No matching upstream report found (11 October 2026).
Low after review (13)
From the report
const target = active()?.wallet;
select(null);
try {
The selection is cleared before namespace.disconnect() runs. If the disconnect is rejected, selectedName stays null while the wallet is still connected. The snapshot then shows connected = true with no selected wallet and no signIn, and connect() throws "Select a wallet before connecting". The UI ends up in a state the user cannot get out of.
Clear the selection only after the disconnect succeeds, or restore the previous selection in the catch block. A more robust option is to derive the selection from the wallet client's own state.
Review: wallet-controller.ts:259-262: Ordering defect is real. Consequence checked in getSnapshot (520-565): after a rejected disconnect, connected is still true, wallet is view(connected.wallet) (the connected wallet is still displayed), signMessage/signTransaction/sendTransaction use active() and keep working. What breaks: selectedWallet is null, signIn is undefined, and connect() throws "Select a wallet". The user is NOT stuck: calling disconnect() again retries (select(null) is a no-op), and select(name) then connect() works. A rejected Wallet Standard disconnect is also rare. Fair LOW.
Upstream: No matching upstream report found (11 October 2026).
packages/web3.js/src/connection.ts:3650From the report
signal.addEventListener('abort', () => {
reject(signal.reason);
});
An application that reuses one long-lived AbortSignal across many confirmations collects one listener and closure per call. That memory is not released until the signal is aborted or garbage-collected.
Register the listener with {once: true} and remove it in the callers' finally blocks.
Review: connection.ts:3642-3655 getCancellationPromise adds signal.addEventListener('abort', ...) with no {once} and no removal; called at 3821, 3921, 6169. Accurate, minor memory growth for a long-lived signal.
From the report
() => ({connection: new Connection(endpoint, config)}),
[endpoint, config],
A Connection owns a websocket channel and a subscription registry. A new one is created whenever endpoint or config change, and the old one is never closed. If config is passed inline, a new Connection is built on every render. That discards the blockhash cache and leaves the previous socket running.
Compare config by value before rebuilding. Give Connection a close or dispose method and call it from an effect cleanup.
Review: ConnectionProvider.tsx:36-39 useMemo(() => ({connection: new Connection(endpoint, config)}), [endpoint, config]); DEFAULT_CONFIG is hoisted (line 28), so the problem only arises with an inline config. grep dispose|close in connection.ts finds no Connection dispose/close; sockets are lazy (only after a subscription) and idle-close (scheduleIdleClose) closes empty channels, so the leak is limited to old connections that still hold subscriptions. Accurate, LOW.
From the report
else globalThis.localStorage?.setItem(key, JSON.stringify(value));
Each hook instance keeps its own copy of the value and only writes to storage. Another instance, or another tab, that writes the same key is never seen, so whichever instance writes last overwrites the stored value. The keys are chosen by the caller and have no namespace.
Subscribe to the storage event and to an in-page notifier, for example with useSyncExternalStore. Prefix the keys with an owner name.
Review: useLocalStorage.ts:20-65: per-instance state, writes via setItem (line 59), no storage event. Exported from index.ts:5. Accurate.
packages/web3.js/src/connection.ts:4703From the report
rpcCommitment == null && minContextSlot == null
? this._typedRpc.getLatestBlockhash()
: this._typedRpc.getLatestBlockhash({
_resolveCommitment always returns a value, falling back to 'confirmed' at line 3658. The many rpcCommitment == null branches therefore never run. The comment near line 6091 says the default is finalized, and sendAndConfirmTransaction falls back to finalized. Maintainers cannot tell which default is intended.
Remove the dead branches, define the default commitment in one place, and correct the comment.
Review: connection.ts:4703 rpcCommitment == null && minContextSlot == null ? ...; _resolveCommitment (3657-3659) returns requestedCommitment ?? this._commitment ?? 'confirmed', never null. 16 occurrences of rpcCommitment == null. Comment at 6091 says server default is finalized; utils/send-and-confirm-transaction.ts:41 falls back to 'finalized'. Inconsistent defaults confirmed.
connection.ts is a single file of about 6,600 linespackages/web3.js/src/connection.ts:2728From the report
export class Connection {
One class holds the blockhash cache and its polling, three confirmation strategies, the subscription facade, HTTP transport and retry, and around 130 RPC wrappers. Changes in one part are hard to review in isolation. Finding 12 shows that defaults have already drifted inside the class.
Move the blockhash cache, the confirmation strategies and the subscription facade into their own modules, and keep Connection as a thin facade.
Review: wc -l: connection.ts 6,659; stake.ts 1,584; legacy.ts 1,052. connection.ts:2728 export class Connection; stake.ts:1081 export class StakeProgram, with Authorized (65), Lockup (85), StakeAccount (167), StakeInstruction (604) in the same file (five classes, as claimed); legacy.ts:257 export class Transaction. Maintainability only.
programs/stake.ts combines five unrelated classesFrom the report
export class StakeProgram {
State decoding, instruction decoding and instruction builders share one file of about 1,600 lines, although they have no state in common.
Split the file into state, instruction-decoding and builder modules, and re-export them from the current path.
Review: wc -l: connection.ts 6,659; stake.ts 1,584; legacy.ts 1,052. connection.ts:2728 export class Connection; stake.ts:1081 export class StakeProgram, with Authorized (65), Lockup (85), StakeAccount (167), StakeInstruction (604) in the same file (five classes, as claimed); legacy.ts:257 export class Transaction. Maintainability only.
transaction/legacy.ts combines compiling, signing, parsing and serialisingFrom the report
export class Transaction {
The wire parser in this file has the problem described in finding 13. It is harder to harden while it sits inside the signing and compiling class.
Move TransactionInstruction and the wire parse and populate logic into separate modules.
Review: wc -l: connection.ts 6,659; stake.ts 1,584; legacy.ts 1,052. connection.ts:2728 export class Connection; stake.ts:1081 export class StakeProgram, with Authorized (65), Lockup (85), StakeAccount (167), StakeInstruction (604) in the same file (five classes, as claimed); legacy.ts:257 export class Transaction. Maintainability only.
packages/web3.js/src/connection.ts:5636From the report
while (this._pollingBlockhash) {
await sleep(100);
}
Callers that wait check a boolean every 100 ms. If the refresh fails, each of them starts its own refresh. A call with disableCache = true runs _pollNewBlockhash directly, and when it finishes it clears the shared flag while another refresh is still running.
Keep a single in-flight promise for the refresh, return it to concurrent callers, and clear it in finally.
Review: connection.ts:5636-5638 while (this._pollingBlockhash) { await sleep(100); }; _pollNewBlockhash sets/clears the shared flag in try/finally (5653, 5684), and disableCache=true callers call it directly, so one finishing clears the flag while another still runs. Accurate; harmless in practice.
From the report
const {
context: {slot: minContextSlot},
value: {blockhash, lastValidBlockHeight},
} = await connection.getLatestBlockhashAndContext();
SendTransaction, SendLegacyTransaction, SendV0Transaction and SendV1Transaction differ only in how the message is compiled. A fix to the shared flow has to be made four times. Because this is example code, some repetition may be intentional.
Extract one shared send-and-confirm helper that takes a transaction builder.
Review: Four send components exist (SendTransaction, SendLegacyTransaction, SendV0Transaction, SendV1Transaction); SendLegacyTransaction.tsx:29 is the getLatestBlockhashAndContext destructure. Example code. (Positive: SendTransaction.tsx:43-48 checks status.err.)
From the report
- name: Verify install as a consumer
run: pnpm --filter @solana/wallet-adapter run test:install
The install check packs its own tarballs, which have a different manifest. The tarballs that are actually published, built after devDependencies are removed, are only checked by inspecting the manifest and confirming that entry-point files exist. A packaging error that only appears on import could be published.
Run the install-and-import smoke test against the exact .npm-pack/*.tgz files before npm publish.
Review: The published .npm-pack/*.tgz (built after pnpm pkg delete devDependencies, lines ~198-210) are only checked for manifest cleanliness and entry-point file presence. Accurate. Process hardening.
From the report
- name: Run unit tests
run: pnpm run test:unit
The validator-backed integration tests and the browser test run in CI but not in the publish job. A release can be triggered from a commit whose CI failed.
Add the integration tests to the publish job, or require a green CI run on the release commit.
Review: typescript-publish.yml:191-192 Run unit tests. The workflow is workflow_dispatch only, guarded to main (lines 3, 36-44), run by a maintainer (dry-run default true). The integration tests live in ci.yml. "Can be triggered from a commit whose CI failed" is true but is a manual-process matter. LOW.
From the report
sh -c "$(curl -sSfL https://release.anza.xyz/$version/install)" init $version --data-dir $TARGET_DIR --no-modify-path
The validator version comes from an unauthenticated query for the newest GitHub release at run time. The installer is then piped straight into a shell, with no checksum or signature check. CI results therefore cannot be reproduced, and CI trusts whatever the host serves. If the version lookup fails, get-latest-validator-release-version.sh prints a Unix timestamp, and that timestamp is passed to the installer as a version, which produces a confusing failure.
Pin a known validator version in the repository and check the installer or binary against a published checksum before running it. If the version cannot be determined, fail with a clear error.
Review: But: the host is Anza's own official install endpoint (the documented install method), the only caller is ci.yml through the setup-validator action (contents: read, no secrets. used in ci.yml, 30-minute job), and the timestamp failure just makes the install fail visibly. The unpinned "latest" version is a reproducibility problem more than a supply-chain one. Fair LOW.
Upstream: No matching upstream report found (11 October 2026).
Info after review (6)
From the report
const input: SolanaSignInInput = {
address: address ?? undefined,
domain: window.location.host,
The sign-in request has no single-use nonce, no issuedAt and no expirationTime, and the signature is checked only in the browser. If a developer copies this pattern into a real login, an attacker who captures one signed sign-in proof can replay it indefinitely. SignMessage.tsx signs a fixed text in the same way.
Have a backend issue a single-use nonce and timestamps. Put them in the sign-in input, and verify the signature and the nonce on the server. If the component is meant only as a demo, add a comment saying it is not a complete authentication flow.
Review: SignIn.tsx:22-24: const input: SolanaSignInInput = { address: address ?? undefined, domain: window.location.host, ..., verified in-browser by verifySignIn(input, output). A demo with nothing to replay against. INFO. Example.
From the report
const controller = createWalletController({
...options,
onError: (error, adapter) => errorRef.current?.(error, adapter),
Some browser wallets refuse to connect from a plain-HTTP origin other than localhost, and they fail without a clear message. I searched the provider and the example app for isSecureContext and location.protocol and found neither. The developer therefore sees an unexplained connection failure.
Before connecting, check window.isSecureContext. If it is false, report a clear error through onError, for example "Serve this page over HTTPS".
Review: DX gap only; wallets refuse insecure origins themselves.
packages/web3.js/src/connection.ts:3966From the report
sleep(2000),
cancellationPromise.catch(() => cancellationSentinel),
Each confirmation polls the nonce account every 2 seconds, and block-height-based confirmation polls getBlockHeight every second, once per call. An account-change subscription already exists in the library. With many concurrent confirmations, the RPC traffic grows linearly with the number of confirmations.
Use an account subscription on the nonce account and fall back to polling only if the subscription fails. Share one block-height poller across concurrent confirmations.
Review: connection.ts:3966 sleep(2000) in the durable-nonce loop (3921-3980); block-height strategy sleep(1000) at 3848. Accurate; design choice, linear RPC load per concurrent confirmation. INFO.
Lockup.default is a shared, mutable instance used as a defaultFrom the report
static default: Lockup = new Lockup(0, 0, PublicKey.default);
StakeProgram.initialize falls back to Lockup.default (line 1106). If any code changes its fields, every later stake initialization in the process uses the changed lockup, and new stake accounts could be locked without anyone noticing.
Return a new, frozen Lockup from a getter, or make the fields readonly and freeze the instance.
Review: stake.ts:105 static default: Lockup = new Lockup(0, 0, PublicKey.default); fields are mutable (lines 88-93); StakeProgram.initialize uses it at 1106. For harm, code must deliberately write to the public static default. Standard mutable-singleton hygiene. INFO.
connect() calls start new wallet connections instead of joining the pending oneFrom the report
await namespace.connect(target);
If the user double-clicks or taps repeatedly, each tap starts a new connect request and the earlier request is aborted. The wallet prompt can then reopen, and earlier callers receive a "superseded" rejection.
Keep the pending connect promise and return it until it settles.
Review: wallet-controller.ts:249 await namespace.connect(target);. The superseding is deliberate and handled: superseded() (134-142) says "Kit rejects a request superseded by a newer connect or sign-in; nothing failed" and connect() re-throws it at line 251 without reporting. The behaviour of the Kit plugin itself (external) I could not read. The reopened-prompt effect is a UX nit. INFO.
console.error from many placesFrom the report
console.error('ws error:', normalizedError.message);
About nine direct console.error calls are spread across registry.ts, controller.ts and runtime.ts, and the same message is logged at two sites. Applications cannot silence or rate-limit these logs, which matters during the reconnect loop described in finding 3.
Route these calls through one internal logger that applications can configure.
Review: 9 console.error calls in rpc-subscriptions/*.ts (count matches). runtime.ts:725 console.error('ws error:', normalizedError.message); is in the else branch of an if whose other branch calls _disconnectChannel (which logs, 753): for one event only one of the two fires, so "the same message is logged at two sites" is only true of the code, not of one failure. Configurable logging is a nice-to-have. INFO.
Excluded on review (2)
Findings the review showed to be wrong or a repeat of another finding. They are not counted above.
- 6. The cached blockhash ignores the requested commitment and minimum context slot (wrong):
connection.ts:5631-5647_blockhashWithExpiryBlockHeight(disableCache)takes no commitment or minContextSlot parameter, and_pollNewBlockhash(5652-5686) fetchesgetLatestBlockhash('finalized'). - 11.
Loader.chunkSizeis a process-wide mutable setting (wrong):loader.ts:72static is real, but the upload path copies it once before the loop:loader.ts:182const chunkSize = Loader.chunkSize;, then offset arithmetic uses the local (187, 192, 218-219).