Sample audits · Open-source projects, first eight

solana-foundation/solana-web3.js

The JavaScript SDK for Solana.

Auditedsolana-foundation/solana-web3.js at commit 8837d96f7ce5b446c5f95f1a6dd58647b1eb02b1
Date11 October 2026
How it ranAPI run on the Nacodex server, full audit, Standard review
Verdict after reviewPass with notes (rule: Fail if a High finding remains after review, otherwise Pass with notes)
0
High after review
4
Medium after review
13
Low after review
6
Info after review
2
excluded on review
How to read this page

Each finding keeps the number it has in the audit report. The rating shown first is the one after review; the first automated rating is listed with it. 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)

3. Medium Websocket reconnects immediately, with no backoff, when the endpoint is unreachable
First rating: Medium · Reviewed rating: Medium · Review: confirmed
From the report
Evidence
this._disconnectChannel(
  SUBSCRIPTION_CHANNEL_CLOSE_CODE_UNEXPECTED,
  error instanceof Error ? error : new Error(String(error)),
Why it matters

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.

Suggested fix, not tested

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

13. Medium The wire-transaction parser does not limit input size or check account indexes
First rating: Medium · Reviewed rating: Medium · Review: confirmed
From the report
Evidence
transaction.signatures.some(
  keyObj => keyObj.publicKey.toString() === pubkey.toString(),
) || message.isAccountSigner(account),
Why it matters
  • Transaction.from and Message.from accept input of any length, even though a real transaction is at most 1,232 bytes.
  • For every account reference in every instruction, populate scans 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 unhelpful TypeError instead of a clear parse error.
Suggested fix, not tested

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

23. Medium Removing one subscription silently cancels another that uses the same callback
First rating: Medium · Reviewed rating: Medium · Review: confirmed
From the report
Evidence
(subscription.callbacks as Set<SubscriptionConfig['callback']>).delete(
  callback,
);
Why it matters

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.

Suggested fix, not tested

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

24. Medium A third-party GitHub Action with write access is referenced by a mutable tag
First rating: Medium · Reviewed rating: Medium · Review: confirmed
From the report
Evidence
uses: peaceiris/actions-gh-pages@v4
with:
  github_token: ${{ secrets.GITHUB_TOKEN }}
Why it matters

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.

Suggested fix, not tested

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)

4. Low A failed disconnect leaves the wallet selection cleared while the wallet stays connected
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
const target = active()?.wallet;
select(null);
try {
Why it matters

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.

Suggested fix, not tested

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

7. Low Abort listeners are added on every confirmation and never removed
First rating: Low · Reviewed rating: Low · Review: confirmed
From the report
Evidence
signal.addEventListener('abort', () => {
  reject(signal.reason);
});
Why it matters

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.

Suggested fix, not tested

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.

8. Low The React connection provider never closes the connections it creates
First rating: Low · Reviewed rating: Low · Review: confirmed
From the report
Evidence
() => ({connection: new Connection(endpoint, config)}),
  [endpoint, config],
Why it matters

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.

Suggested fix, not tested

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.

9. Low The exported local-storage hook does not stay in sync across instances or tabs
First rating: Low · Reviewed rating: Low · Review: confirmed
From the report
Evidence
else globalThis.localStorage?.setItem(key, JSON.stringify(value));
Why it matters

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.

Suggested fix, not tested

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.

12. Low Unreachable default-commitment branches, and a comment that contradicts the code
First rating: Low · Reviewed rating: Low · Review: confirmed
From the report
Evidence
rpcCommitment == null && minContextSlot == null
  ? this._typedRpc.getLatestBlockhash()
  : this._typedRpc.getLatestBlockhash({
Why it matters

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

Suggested fix, not tested

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.

14. Low connection.ts is a single file of about 6,600 lines
First rating: Low · Reviewed rating: Low · Review: confirmed
From the report
Evidence
export class Connection {
Why it matters

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.

Suggested fix, not tested

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.

15. Low programs/stake.ts combines five unrelated classes
First rating: Low · Reviewed rating: Low · Review: confirmed
From the report
Evidence
export class StakeProgram {
Why it matters

State decoding, instruction decoding and instruction builders share one file of about 1,600 lines, although they have no state in common.

Suggested fix, not tested

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.

16. Low transaction/legacy.ts combines compiling, signing, parsing and serialising
First rating: Low · Reviewed rating: Low · Review: confirmed
From the report
Evidence
export class Transaction {
Why it matters

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.

Suggested fix, not tested

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.

17. Low Concurrent blockhash refreshes are coordinated with a flag and a sleep loop instead of a shared promise
First rating: Low · Reviewed rating: Low · Review: confirmed
From the report
Evidence
while (this._pollingBlockhash) {
  await sleep(100);
}
Why it matters

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.

Suggested fix, not tested

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.

19. Low The example send components repeat the same send-and-confirm flow four times
First rating: Low · Reviewed rating: Low · Review: confirmed
From the report
Evidence
const {
  context: {slot: minContextSlot},
  value: {blockhash, lastValidBlockHeight},
} = await connection.getLatestBlockhashAndContext();
Why it matters

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.

Suggested fix, not tested

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

21. Low The release does not install and import the exact tarballs it publishes
First rating: Low · Reviewed rating: Low · Review: confirmed
From the report
Evidence
- name: Verify install as a consumer
  run: pnpm --filter @solana/wallet-adapter run test:install
Why it matters

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.

Suggested fix, not tested

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.

22. Low The release gate runs unit tests only
First rating: Low · Reviewed rating: Low · Review: confirmed
From the report
Evidence
- name: Run unit tests
  run: pnpm run test:unit
Why it matters

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.

Suggested fix, not tested

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.

25. Low CI downloads and runs an unverified remote installer for the newest release
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
sh -c "$(curl -sSfL https://release.anza.xyz/$version/install)" init $version --data-dir $TARGET_DIR --no-modify-path
Why it matters

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.

Suggested fix, not tested

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)

1. Info Example sign-in has no server-issued nonce or expiry
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
const input: SolanaSignInInput = {
  address: address ?? undefined,
  domain: window.location.host,
Why it matters

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.

Suggested fix, not tested

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.

2. Info No check that the page runs in a secure context before connecting a wallet
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
const controller = createWalletController({
  ...options,
  onError: (error, adapter) => errorRef.current?.(error, adapter),
Why it matters

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.

Suggested fix, not tested

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.

5. Info Durable-nonce confirmation polls the nonce account instead of subscribing to it
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
sleep(2000),
cancellationPromise.catch(() => cancellationSentinel),
Why it matters

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.

Suggested fix, not tested

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.

10. Info Lockup.default is a shared, mutable instance used as a default
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
static default: Lockup = new Lockup(0, 0, PublicKey.default);
Why it matters

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.

Suggested fix, not tested

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.

18. Info Repeated connect() calls start new wallet connections instead of joining the pending one
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
await namespace.connect(target);
Why it matters

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.

Suggested fix, not tested

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.

20. Info Subscription errors are logged with console.error from many places
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
console.error('ws error:', normalizedError.message);
Why it matters

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.

Suggested fix, not tested

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.

Want this for your code?

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