| Audited | otter-sec/anchor at commit 50c68b9b9b57ce7a6f6014e707febef9abb33401 |
|---|---|
| Date | 11 October 2026 |
| How it ran | cloud session, 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. 21 findings were first rated Medium or High; 3 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 (3)
client/src/lib.rs:414 (also lines 368-377 and 277)From the report
let events = parse_logs(&logs.value.logs, &program_id_str)?;
for e in events {
f(&ctx, e);
Any parse error ends the subscription task through ?. This includes a log line that carries a known event prefix but does not decode, which a program echoing user text can produce. The error is then discarded by let _ = self.handle.await. The PubsubClient is cached in a OnceCell forever, even after its websocket has died. Callers stop receiving events with no signal.
Log the error and continue per message. Report task termination to the caller through a channel or the unsubscribe result. Clear or rebuild the cached pubsub client when its stream ends.
Review: on_internal returns from the spawned task with ? on any parse_logs error (let events = parse_logs(...)?;, lib.rs:414) and when the notification stream ends the while let simply finishes with Ok(()). unsubscribe_internal throws the result away (let _ = self.handle.await;, lib.rs:274-277), and the PubsubClient stays in the OnceCell (get_or_try_init, never reset), so there is no reconnect. A long-running listener therefore goes deaf with no signal to the caller, and a dropped websocket is a normal production event. The "bad log" trigger is narrower (needs an 8-byte event discriminator prefix followed by a body that fails borsh decode), but handle_program_log does .transpose()? on exactly that. Shipped crate (anchor-client). Upstream: two searches (event subscription websocket parse_logs, PubsubClient reconnect), no match found.
Upstream: No matching upstream report found (11 October 2026).
anchor new can delete an existing lib.rs without --forcecli/src/template.rs:78From the report
// Remove the stub version
fs::remove_file(&lib_rs_path)?;
create_files skips paths that already exist, but the stub lib.rs is then removed unconditionally and replaced by the template. anchor new refuses only when the name is registered for the current cluster (cli/src/lib.rs:2093). If programs/<name>/src/lib.rs exists but is registered only under another cluster, or not at all, the user's program source is deleted and overwritten.
Refuse to proceed when programs/<name> already exists and --force is not given. At minimum, delete the stub only if this run created it.
Review: create_program writes an empty stub lib.rs through create_files (which skips existing paths), then calls fs::remove_file(&lib_rs_path)? unconditionally (template.rs:78) and re-creates it from the template. The only guard is programs.contains_key(&name) against cfg.programs[cluster] in new (lib.rs:2093). A program directory that exists but is absent from the current cluster's [programs.*] table is silently emptied and replaced. The trigger is narrow (name collision plus unregistered), and the source is usually in version control, but it is an unprompted destruction of user source with a trivial fix. Shipped CLI. Upstream: two searches (anchor new overwrite lib.rs existing program, anchor new force programs already exists), no match found.
Upstream: No matching upstream report found (11 October 2026).
cli/src/fetch/chunks.rs:72From the report
.filter_map(|instruction| extract_compressed_chunk(&instruction.data).ok().flatten())
Chunks are recognised by their data prefix alone. The code never checks that the instruction targets the program or that its first account is the IDL account. Anyone can list the IDL account in their own transaction and inject fake "historical" IDLs, which anchor idl fetch --history then writes to disk as that program's history.
Resolve each instruction's program id and accounts against the message keys, and accept only instructions to the target program whose first account is the IDL account, as the metadata path already does.
Review: extract_from_raw_instructions / extract_from_parsed_instructions (chunks.rs:72) accept any instruction whose data starts with the 8-byte IDL tag plus variant 0x02; the program id and account list are never checked. Signatures come from get_signatures_for_address(<IDL account>) (rpc.rs:51), so any successful transaction that merely lists that address read-only (with the forged bytes sent to any program that ignores or tolerates its data) enters the history, and a forged zlib session of JSON is written out as a historical IDL. Failed transactions are filtered (sig.err, rpc.rs:135), which does not stop a crafted successful one. Impact is forged provenance in a forensic/history feature, not a change to the live IDL, so this is borderline MEDIUM/LOW. Shipped CLI (anchor idl fetch --history). Upstream: two searches (idl fetch history chunks, idl history fake OR forged OR spoof) returned only the feature's own PR #3992 and issue #3941, not this defect; no match found.
Upstream: No matching upstream report found (11 October 2026).
Low after review (35)
ts/packages/anchor/src/utils/token.ts:18From the report
[owner.toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()],
ASSOCIATED_PROGRAM_ID
associatedAddress always derives the address with the legacy token program id, and the package has no Token-2022 program id (searched ts/packages/anchor/src). For a Token-2022 mint it returns an address that is not that mint's real associated token account, so transfers or lookups built on it fail or go to the wrong account.
Add an optional tokenProgramId parameter that defaults to the legacy id, and export the Token-2022 id. Callers can then read the mint's owner program and pass the matching id.
Review: Real limitation (Token-2022 not supported, TOKEN_PROGRAM_ID hard-coded at token.ts:18), but it is a feature gap in an optional helper that nothing else in the package calls; callers hit a wrong address only if they use it with Token-2022 mints.
ts/packages/anchor/src/provider.ts:260From the report
const signedTxs = await this.wallet.signAllTransactions(txs);
...
const tx = signedTxs[k];
sendAll assumes the wallet returned one signed transaction for each input. If a wallet returns fewer, tx becomes undefined and the loop throws after earlier transactions have already been sent, leaving a partly executed batch and an unclear error.
Before sending anything, check that signedTxs.length === txs.length and that each transaction carries the fee payer's signature. If not, fail with an explicit "wallet rejected or returned incomplete signatures" error.
From the report
await this._provider!.connection.removeOnLogsListener(
this._onLogsSubscriptionId
);
this._onLogsSubscriptionId = undefined;
The subscription id is cleared only after the await. An addEventListener call made during that window sees the old id at line 95, skips creating a new onLogs subscription, and then the id is wiped. The new listener never receives events, and nothing reports it.
Take the id into a local variable and clear the field before awaiting the removal, or serialise add and remove through one promise chain.
Review: The race is real (_onLogsSubscriptionId = undefined after await removeOnLogsListener, event.ts:157; add at line 95 returns early on the stale id), but it needs an add during the single RPC round trip of a teardown. Narrow window.
ts/packages/anchor/src/program/namespace/account.ts:292 (registry declared at line 397)From the report
const sub = subscriptions.get(address.toString());
if (sub) {
return sub.ee;
The registry is module-global. If two AccountClients (different account types, programs, providers or commitments) subscribe to the same address, the second one gets the first one's emitter, and the data is decoded with the first client's layout and connection. unsubscribe then removes the listener on the wrong connection.
Keep the registry per client instance, or key it by connection, program id, account name and commitment. Store the owner in each entry and refuse a mismatched reuse.
Review: The registry is module-global and keyed by address only (account.ts:292, 397), but one address holds one account type at a time, so the realistic harm is only multi-connection or multi-commitment apps.
cli/src/debugger/tui.rs:732 (file read at line 1052)From the report
let resolved_path = resolve_src_path(
The resolution and the file read are recomputed on every frame, even though their inputs do not change during a session. This wastes CPU and disk I/O while stepping.
Memoise the resolved path per (file, root) next to the existing file cache, and reuse the cached lines.
cli/src/debugger/gdb.rs:143From the report
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(20));
}
This causes about 50 wake-ups per second for the whole session even when nothing happens.
Use a blocking accept(). On shutdown, unblock it by connecting to the socket once after setting the stop flag, or use poll with a wake pipe.
avm/src/lib.rs:1092From the report
let needs_download =
cached_version.as_deref() != Some(manifest.version.as_str()) || !nightly_binaries_exist();
If the second of the two installs fails, the binaries end up at different versions, while the cache still says one version is current. The cached path may then be taken and the mismatch kept.
Record the version per artifact, or write the cache only after both installs succeed and clear it before starting.
avm/src/lib.rs:613 (same pattern at line 295)From the report
let mut current_version_file = fs::File::create(current_version_file_path())?;
current_version_file.write_all(version.to_string().as_bytes())?;
The file is truncated and then written from several places. An interrupted write leaves it empty or garbled, and every later avm and anchor call then misreads the active version.
Add one write_current_version helper that writes a temp file and renames it (the code already has an atomic install helper), and use it everywhere.
cli/src/lib.rs:3557 (same pattern near lines 2089, 2655, 3288)From the report
std::env::set_current_dir(program_path)?;
let idl = generate_idl(&cfg, skip_lint, no_docs, &cargo_args)?;
std::env::set_current_dir(current_dir)?;
If generate_idl fails, the CLI stays in the program directory, and any later code in the same process resolves relative paths against the wrong place.
Use an RAII guard that restores the directory on drop, or pass the directory to subprocesses with Command::current_dir.
cli/src/fetch/decompress.rs:26From the report
let consumed = decoder.total_in() as usize;
if is_complete_idl_json(&out) {
streams.push(out);
Each stream is capped at 16 MiB, but every stream that parses as JSON is kept. Highly compressible streams of about 16 KB each can make about 1 MB of crafted on-chain data hold more than 1 GiB in memory during anchor idl fetch.
Apply one shared budget for total decompressed bytes and stream count, and fail with a clear error once it is exceeded.
Review: Verified in code: decompress.rs keeps every JSON-valid 16 MiB stream, legacy_idl.rs:249 uses read_to_end with no take, legacy_idl.rs:248 slices account.data[44..44 + compressed_len] (panic), pmp.rs:921 bs58::decode has no size cap, idl.ts:475-477 ungzip/inflate unbounded, lib.rs:4260 Vec::with_capacity(size) from an untrusted u32. All real, but the attacker must be the writer of the IDL/metadata/account that the developer chooses to fetch, the failure is a loud abort/hang/OOM of a developer CLI or script, nothing is corrupted, and the legacy path (#12, #13) sits in a deprecated legacy-idl subcommand while the new fetcher already has bounded readers (pmp.rs:953-969).
cli/src/legacy_idl.rs:249From the report
let mut z = ZlibDecoder::new(compressed_bytes);
let mut s = Vec::new();
z.read_to_end(&mut s)?;
The account contents are controlled by whoever wrote them. A compression bomb in an IDL account can exhaust memory on the machine running the fetch. The newer fetch module already guards against this with a take(MAX + 1) reader.
Reuse the same bounded reader and limit here, and fail when the limit is exceeded.
Review: Verified in code: decompress.rs keeps every JSON-valid 16 MiB stream, legacy_idl.rs:249 uses read_to_end with no take, legacy_idl.rs:248 slices account.data[44..44 + compressed_len] (panic), pmp.rs:921 bs58::decode has no size cap, idl.ts:475-477 ungzip/inflate unbounded, lib.rs:4260 Vec::with_capacity(size) from an untrusted u32. All real, but the attacker must be the writer of the IDL/metadata/account that the developer chooses to fetch, the failure is a loud abort/hang/OOM of a developer CLI or script, nothing is corrupted, and the legacy path (#12, #13) sits in a deprecated legacy-idl subcommand while the new fetcher already has bounded readers (pmp.rs:953-969).
cli/src/legacy_idl.rs:248 (also lines 244 and 247)From the report
let compressed_len: usize = idl_account.data_len.try_into().unwrap();
let compressed_bytes = &account.data[44..44 + compressed_len];
data_len comes from the account. If it claims more bytes than the account holds, or the account is too short, the CLI panics and gives no useful error.
Use account.data.get(44..44usize.checked_add(len)?) and return a descriptive error. Check the minimum length before cutting off the discriminator.
Review: Verified in code: decompress.rs keeps every JSON-valid 16 MiB stream, legacy_idl.rs:249 uses read_to_end with no take, legacy_idl.rs:248 slices account.data[44..44 + compressed_len] (panic), pmp.rs:921 bs58::decode has no size cap, idl.ts:475-477 ungzip/inflate unbounded, lib.rs:4260 Vec::with_capacity(size) from an untrusted u32. All real, but the attacker must be the writer of the IDL/metadata/account that the developer chooses to fetch, the failure is a loud abort/hang/OOM of a developer CLI or script, nothing is corrupted, and the legacy path (#12, #13) sits in a deprecated legacy-idl subcommand while the new fetcher already has bounded readers (pmp.rs:953-969).
cli/src/fetch/pmp.rs:921From the report
MetadataEncoding::Base58 => bs58::decode(bytes).into_vec().map_err(|err| {
Base58 decoding takes time roughly quadratic in input size, and the uploader of the metadata chooses the encoding. A payload near the 16 MiB byte cap can keep the CLI busy for a very long time.
Apply a much smaller size limit to Base58 payloads (for example 256 KiB), and reject anything larger before decoding.
Review: Verified in code: decompress.rs keeps every JSON-valid 16 MiB stream, legacy_idl.rs:249 uses read_to_end with no take, legacy_idl.rs:248 slices account.data[44..44 + compressed_len] (panic), pmp.rs:921 bs58::decode has no size cap, idl.ts:475-477 ungzip/inflate unbounded, lib.rs:4260 Vec::with_capacity(size) from an untrusted u32. All real, but the attacker must be the writer of the IDL/metadata/account that the developer chooses to fetch, the failure is a loud abort/hang/OOM of a developer CLI or script, nothing is corrupted, and the legacy path (#12, #13) sits in a deprecated legacy-idl subcommand while the new fetcher already has bounded readers (pmp.rs:953-969).
ts/packages/anchor/src/idl.ts:475 (also line 477 and line 496)From the report
return Buffer.from(ungzip(data));
case MetadataCompression.Zlib:
return Buffer.from(inflate(data));
Metadata comes from on-chain accounts, and the account resolver can fetch it for programs the developer never named. A gzip or zlib bomb can crash a Node process or a browser tab. The bs58.encode call at line 496 is quadratic and blocks the event loop on large payloads.
Use pako's streaming inflater and abort once the output passes a fixed limit, matching the 16 MiB cap in the Rust fetcher. Reject Base58 payloads above a small size.
Review: Verified in code: decompress.rs keeps every JSON-valid 16 MiB stream, legacy_idl.rs:249 uses read_to_end with no take, legacy_idl.rs:248 slices account.data[44..44 + compressed_len] (panic), pmp.rs:921 bs58::decode has no size cap, idl.ts:475-477 ungzip/inflate unbounded, lib.rs:4260 Vec::with_capacity(size) from an untrusted u32. All real, but the attacker must be the writer of the IDL/metadata/account that the developer chooses to fetch, the failure is a loud abort/hang/OOM of a developer CLI or script, nothing is corrupted, and the legacy path (#12, #13) sits in a deprecated legacy-idl subcommand while the new fetcher already has bounded readers (pmp.rs:953-969).
anchor account trusts a 32-bit element count from account datacli/src/lib.rs:4260From the report
let mut vec_data: Vec<JsonValue> = Vec::with_capacity(size);
for _ in 0..size {
size is read directly from the account bytes and can be up to about 4.29 billion. with_capacity then requests up to about 137 GB, which aborts the process. With zero-sized element types the loop can run billions of times.
Reject a size larger than the remaining bytes, do not preallocate from untrusted counts (or clamp the preallocation), and add an iteration budget. Apply the same rule to fixed-length arrays.
Review: Verified in code: decompress.rs keeps every JSON-valid 16 MiB stream, legacy_idl.rs:249 uses read_to_end with no take, legacy_idl.rs:248 slices account.data[44..44 + compressed_len] (panic), pmp.rs:921 bs58::decode has no size cap, idl.ts:475-477 ungzip/inflate unbounded, lib.rs:4260 Vec::with_capacity(size) from an untrusted u32. All real, but the attacker must be the writer of the IDL/metadata/account that the developer chooses to fetch, the failure is a loud abort/hang/OOM of a developer CLI or script, nothing is corrupted, and the legacy path (#12, #13) sits in a deprecated legacy-idl subcommand while the new fetcher already has bounded readers (pmp.rs:953-969).
lang/syn/src/idl/accounts.rs:11From the report
let resolution = option_env!("ANCHOR_IDL_BUILD_RESOLUTION")
.map(|val| val == "TRUE")
.unwrap_or_default();
When the variable is unset or written as true, resolution information is dropped from the generated IDL without a warning. The IDL builder, by contrast, defaults to enabling resolution.
Make the unset default match the builder default. Parse the value case-insensitively in one shared helper used by every IDL generator.
cli/src/lib.rs has grown to about 7,600 lines of mixed concernscli/src/lib.rs:4518From the report
struct TestValidatorPlan {
Docker builds, IDL commands, the account decoder, validator and surfpool orchestration, and key management all live in one file. That makes review and change hard and invites coupling.
Move each cluster into its own module, starting with validator orchestration and the account decoder.
cli/src/program.rs and cli/src/config.rs are also very largecli/src/program.rs:2985, cli/src/config.rs:1963From the report
#[cfg(test)]
These files hold about 2,985 and 1,960 lines of non-test code, each mixing several independent concerns: deploy and buffer handling, authority management and inspection in one; config parsing, workspace discovery, validator defaults and keypair handling in the other.
Split them along those lines into sub-modules.
From the report
if (!this._cache.has(address)) {
const accountInfo = await this._provider.connection.getAccountInfo(
The cache is filled only after the await. Seeds resolved in parallel that read the same account each send their own RPC request, which can include repeated IDL fetches and decompression.
Cache the in-flight promise and remove it if it rejects.
anchor init --force overwrites existing project files without listing themcli/src/lib.rs:1871From the report
let toml = cfg.to_string();
fs::write("Anchor.toml", toml)?;
With --force in an existing directory, Anchor.toml, package.json, tsconfig.json, ignore files, migrations and tests are truncated and rewritten. Custom content is lost and the user is not told which files were replaced.
Print the list of files that will be replaced and ask for confirmation, or keep .bak copies.
solana-verify is downloaded and made executable without verificationavm/src/lib.rs:662From the report
let bin_path = get_bin_dir_path().join("solana-verify");
fs::write(&bin_path, res.bytes()?)?;
The binary is written straight into ~/.avm/bin and set to mode 0775 with no checksum or provenance check, while Anchor binaries in the same file are verified. A tampered release asset or an intercepting proxy would get code execution on the developer's machine. The non-atomic write can also leave a truncated executable that later runs.
Pin the expected SHA-256 for each platform asset, or verify a build attestation, and install through the existing atomic install helper.
Review: #23: solana-verify is fetched over HTTPS from a version-pinned GitHub release URL with no hash (lib.rs:662); a real hardening gap, but the exposure is a compromised upstream release, which is the same trust as most download-and-run tooling. #24: predictable std::env::temp_dir()/solana-install-init-{target}-{version} is written and executed (solana.rs:485-500) without create_new, a genuine CWE-377 pattern, but it is a fallback used only when the primary Solana installer fails (solana.rs:385, 424, 436), and it needs a hostile local user on a shared Linux /tmp (macOS temp dirs are per-user).
avm/src/solana.rs:485From the report
let installer = std::env::temp_dir().join(format!("solana-install-init-{target}-{version}"));
let bytes = download_installer_bytes(&url)
The path in the shared temp directory is predictable. Another local user can pre-create it as a symlink, so fs::write and chmod hit a file of the attacker's choosing, or can swap the content before it runs. The downloaded bytes are executed without any hash check.
Download into a private tempfile::tempdir() and create the file with create_new and mode 0700. Verify a pinned checksum before executing.
Review: #23: solana-verify is fetched over HTTPS from a version-pinned GitHub release URL with no hash (lib.rs:662); a real hardening gap, but the exposure is a compromised upstream release, which is the same trust as most download-and-run tooling. #24: predictable std::env::temp_dir()/solana-install-init-{target}-{version} is written and executed (solana.rs:485-500) without create_new, a genuine CWE-377 pattern, but it is a fallback used only when the primary Solana installer fails (solana.rs:385, 424, 436), and it needs a hostile local user on a shared Linux /tmp (macOS temp dirs are per-user).
avm/install:303From the report
[ "$actual_sha" = "$expected_sha" ] || fail "checksum mismatch for $tool: expected $expected_sha, got $actual_sha"
The manifest that holds the expected hashes comes from the same bucket as the archives (line 293), so the check catches corruption but not a compromised bucket. The Rust updater already verifies attestations.
Verify the attestation when tooling is available, or print a clear notice that provenance was not verified.
cli/src/keygen.rs:130From the report
path.push(".config");
path.push("solana");
path.push("id.json");
The same path is built four times in keygen.rs, once in config.rs and twice in lib.rs. When HOME is unset, one copy panics and another falls back to a literal ~ path, so the copies already behave differently.
Add one default_keypair_path() -> Result<PathBuf> in a shared module, use it everywhere, and return an error instead of panicking.
.github/scripts/publish-npmjs.sh:37From the report
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-.+ ]]; then
publish_args+=(--tag next)
The same regex and tag selection appear in bump-version.sh and twice in release.yaml. Any change to the versioning scheme has to be made in four places, or the release channels drift apart.
Move this logic into one sourced helper script and call it from all of them.
avmavm/src/lib.rs:534From the report
let output = Command::new("rustc").arg("-vV").output()?;
The same parsing already exists as rustc_host_target() (line 1242), so a fix to one copy can miss the other.
Call the existing function.
cli/src/template.rs:3From the report
crate::{
compat::solana_pubkey, config::ProgramWorkspace, create_files, override_or_create_files,
AbsolutePath, Files, PackageManager, VERSION,
lib.rs depends on template.rs, and template.rs imports helpers back from lib.rs. This tight cycle makes both harder to change or test in isolation.
Move the file-creation helpers and shared types into a small leaf module that both depend on.
.github/workflows/release.yaml:389 (line corrected on review; the report cites :410)From the report
cp "$DIST"/anchor-*-x86_64-unknown-linux-gnu/anchor-*-x86_64-unknown-linux-gnu cli/npm-package/anchor
The binary's version comes from the git tag, and the package version comes from package.json. A mismatch would ship a package whose wrapper rejects its own binary, and a published version cannot be republished.
Before npm publish, assert that ./anchor --version equals the package version, and fail the job otherwise.
cli/src/config.rs:1822 (line corrected on review; the report cites :1823)From the report
let mut file = File::create(&path)
.with_context(|| format!("Error creating file with path: {}", path.display()))?;
file.write_all(format!("{:?}", program_kp.to_bytes()).as_bytes())?;
The secret key in target/deploy/<name>-keypair.json gets the umask default (usually 0644), so other local users can read it before the first deploy. The exists()-then-create sequence also lets a concurrent run truncate an existing key.
Reuse the existing secure keypair writer (create_new plus mode 0600).
Review: The program-id keypair is written with File::create default mode (config.rs:1822). Real, but this key only matters for the first deploy of that program id, upgrade authority is the wallet, and the file sits under the project's target/ in the user's own directory.
cli/src/lib.rs:6268 and cli/src/lib.rs:4766From the report
if !ledger_path.is_relative() {
...
fs::remove_dir_all(&ledger_path).with_context(|| {
The ledger path from Anchor.toml is only required to be relative, so "." or "../.." passes and is deleted with remove_dir_all. A cloned project with a hostile Anchor.toml can wipe the workspace or its parent directories on anchor test. In the same way, anchor coverage --trace-dir . deletes the whole workspace (line 4766).
Canonicalise the path, reject .. and .-only forms, and require it to be strictly inside the workspace and not equal to it before deleting.
Review: ledger = "." or ".." passes is_relative() and is removed (lib.rs:6268), and --trace-dir . deletes the workspace trace path (lib.rs:4766). Real footguns, but both are values the user wrote, and a hostile Anchor.toml already controls [scripts] (config.rs:363), which anchor test runs, so the "cloned hostile repo" framing adds nothing.
cli/src/lib.rs:6080From the report
loop {
let resp = client
.send::<RpcResponse<SurfnetInfoResponse>>(
If a runbook never completes, anchor test hangs forever. If the RPC call fails, ? returns and the spawned surfpool process keeps running.
Bound the loop with a timeout and check try_wait() on each pass. Kill and wait for the child on any error or timeout.
Review: Unbounded loop without a timeout and ? that leaves the spawned surfpool running (lib.rs:6080-6095). Real; the effect is a hung anchor test that Ctrl+C ends and a stray local process.
ts/packages/anchor/src/program/namespace/account.ts:302 and ts/packages/anchor/src/coder/borsh/event.ts:62From the report
const account = this._coder.accounts.decode(
this._idlAccount.name,
acc.data
When a subscribed account is closed or reassigned, decode throws "Invalid account discriminator" inside the notification handler, and no event reaches the subscriber. In the event coder, only the base64 step is guarded. A malformed body behind a known discriminator, which can come from user-controlled log text, throws inside the logs callback.
Wrap the decodes in try/catch, skip empty account data, and emit an error event (or return null) instead of throwing.
Review: #36: unguarded decode in the callbacks (account.ts:302, event.ts:62) throws inside a notification handler; loud, per-event, no data harm. #37: the doc comment says "Accounts not found or with wrong discriminator are returned as null", but decode throws "Invalid account discriminator" (accounts.ts:63), so one foreign account fails the batch; a real contract bug but visible at once and easy to work around. #38: Buffer.alloc(1000) with a TODO in all three encoders, RangeError on larger payloads; loud failure, and instruction data is bounded by the 1232-byte transaction limit anyway.
fetchMultiple throws where its documentation promises nullFrom the report
// Decode accounts where discriminator is correct, null otherwise
...
data: this._coder.accounts.decode(this._idlAccount.name, account.data),
The documentation says accounts with the wrong discriminator come back as null, but decode throws, so one foreign account fails the whole batch.
Check the discriminator, or catch per entry, and return null for mismatches as documented.
Review: #36: unguarded decode in the callbacks (account.ts:302, event.ts:62) throws inside a notification handler; loud, per-event, no data harm. #37: the doc comment says "Accounts not found or with wrong discriminator are returned as null", but decode throws "Invalid account discriminator" (accounts.ts:63), so one foreign account fails the batch; a real contract bug but visible at once and easy to work around. #38: Buffer.alloc(1000) with a TODO in all three encoders, RangeError on larger payloads; loud failure, and instruction data is bounded by the 1232-byte transaction limit anyway.
ts/packages/anchor/src/coder/borsh/accounts.ts:51 (same in coder/borsh/types.ts:33 and coder/borsh/instruction.ts:51)From the report
const buffer = Buffer.alloc(1000); // TODO: use a tighter buffer.
Any account, type or instruction payload that serialises to more than 1000 bytes (long strings, vectors, larger structs) fails to encode with a range error. This breaks client-side account construction, test fixtures and large instructions.
Compute the size from the layout first, or grow the buffer on overflow.
Review: #36: unguarded decode in the callbacks (account.ts:302, event.ts:62) throws inside a notification handler; loud, per-event, no data harm. #37: the doc comment says "Accounts not found or with wrong discriminator are returned as null", but decode throws "Invalid account discriminator" (accounts.ts:63), so one foreign account fails the batch; a real contract bug but visible at once and easy to work around. #38: Buffer.alloc(1000) with a TODO in all three encoders, RangeError on larger payloads; loud failure, and instruction data is bounded by the 1232-byte transaction limit anyway.
make publish target omits two required cratesMakefile:36From the report
cd lang/ && cargo publish && cd ../
sleep 25
cd spl/ && cargo publish && cd ../
anchor-lang-error (lang/error) and anchor-cli-macros (cli/cli-macros) are publishable, required dependencies of anchor-lang and anchor-cli, but they are missing from the list. Running the target publishes about ten crates and then fails, leaving a partial release that cannot be undone.
Remove the target, or make it call the same workspace-wide release command the CI release uses, so the crate list cannot drift.
Review: Makefile publish target (Makefile:36) lacks lang/error and cli/cli-macros (both publish = true), so it would fail partway. It is manual maintainer tooling, not shipped, and the CI release workflow is the real path.
Info after review (1)
cli/npm-package/anchor.js:41From the report
console.error(`Only x86_64 / Linux distributed in NPM package right now.`);
This is a permanent condition rather than an event, so it adds the same noise to every CI log and script output.
Fall back to the global binary quietly, and warn only when it is missing or its version differs.