Sample audits · Workstation audits, October 2026

regolith-labs/steel

A compact framework for Solana programs; every program built on it inherits its account loaders and CPI wrappers.

Auditedregolith-labs/steel at commit 59f8e9a5633dc6a3b0f5acfb44693e935e257024
Date11 October 2026
How it ranlocal run on our workstation, 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
6
Medium after review
24
Low after review
0
Info after review
0
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. 23 findings were first rated Medium or High; 6 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 (6)

3. Medium The mutable header loader skips the account-type check
First rating: High · Reviewed rating: Medium · Review: rated too high
From the report
Evidence
fn try_header_from_bytes_mut(data: &mut [u8]) -> Result<(&mut Self, &mut [u8]), ProgramError> {
    let (prefix, remainder) = data[8..].split_at_mut(std::mem::size_of::<T>());
Why it matters

the read-only twin, try_header_from_bytes (line 52), rejects data whose type byte does not match. The mutable version does not. A program that loads a header-plus-body account (for example a merkle tree) through this public method will accept any other account it owns that is long enough, then reinterpret it and write to it as the header type. An attacker can therefore pass one of the program's other account types and corrupt or forge its state through the header path. That is type confusion with write access.

Suggested fix, not tested

add the same Self::discriminator().ne(&data[0]) check as the read-only variant, and a length check before slicing.

Review: Verified: try_header_from_bytes checks discriminator().ne(&data[0]) (deserialize.rs:52) and try_header_from_bytes_mut (line 64) does not; any Pod bit pattern passes bytemuck::try_from_bytes_mut, so a different program-owned account that is long enough is reinterpreted and written. Also no length guard (data[8..].split_at_mut panics on short data). It is a public blanket-impl trait method, so the inconsistency is a true bug and the exploit shape (type confusion with write access) is right. But: the function takes raw &mut [u8], so ownership is the caller's job and nothing in the repo calls it (grep: only the trait and one test of the read-only twin); the only users are programs with a header-plus-body layout (the doc comment names merkle trees), which is a niche pattern; those programs can also call is_type::<T>() first; and the attacker must supply a second account of the same program that is large enough, which is rare for different account types. A library defect that silently drops a validation its sibling has, with write access, is solid MEDIUM; HIGH needs a concrete in-repo consumer. Shipped library. Upstream: one search (header discriminator) returned PR #10 (merged, introduced the header trait) and PR #12 (open, redesign of it); neither discusses this missing check. No match found.

Upstream: No matching upstream report found (11 October 2026).

10. Medium steel new overwrites an existing project and uses the name as an unvalidated path
First rating: Medium · Reviewed rating: Medium · Review: confirmed
Location: steel/cli/src/new_project.rs:175 (path built at line 38)
From the report
Evidence
let base_path = Path::new(&project_name);
...
fs::write(path, content)?;
Why it matters

create_dir_all succeeds when the directory already exists, and every template file is then written with fs::write. Running steel new <existing-name> (or steel new .) silently replaces the existing Cargo.toml, README.md, lib.rs, the program sources and the tests. A name such as ../x writes outside the working directory, and quotes or braces in the name are substituted straight into the generated manifest.

Suggested fix, not tested

refuse a non-empty target directory, open each file with create_new(true), and require names to match [a-z][a-z0-9_-]*.

Review: create_dir_all succeeds on an existing path and stub_file ends in fs::write(path, content) (new_project.rs:47, 175) with no emptiness or exists check, so steel new <existing> or steel new . replaces Cargo.toml, README.md, api/src/*, program/src/* and tests. The "../x" and template-injection parts are the user attacking their own machine and add nothing. Shipped CLI. Upstream: one search (steel new overwrite), no match found.

Upstream: No matching upstream report found (11 October 2026).

14. Medium steel keys sync destroys the existing program keypair instead of syncing to it
First rating: High · Reviewed rating: Medium · Review: rated too high
From the report
Evidence
let new_key = Keypair::new();
replace_prog_id(new_key.insecure_clone().into())?;
let mut lib_rs = fs::OpenOptions::new().create(true).write(true).truncate(true).open(deploy_kp_path)?;
Why it matters

the command is documented as "Sync declared program id to deploy program keypair". It reads the existing keypair (line 82), but whenever lib.rs does not already contain that key, which is the only case in which it does anything, it discards the key, generates a random one and truncates the keypair file. The original program keypair is lost for good: it lives in git-ignored target/, and there is no prompt or backup. Planned or vanity program ids, and the ids of programs already in use elsewhere, are replaced rather than synced. The "synced to" message hides this.

Suggested fix, not tested

pass the keypair already read to replace_prog_id and never write the keypair file in sync.

Review: Verified at program_keys.rs:67-103. The subcommand's own help is "Sync declared program id to deploy program keypair" (args.rs:39): the intended direction is to make lib.rs match the existing keypair. The code reads the keypair (line 82), and when lib.rs does not contain its pubkey it ignores it, calls Keypair::new() (line 93), rewrites declare_id! with the new key and truncates the keypair file with the new secret (95-100). So the command does the opposite of its description and the original keypair is gone without prompt or backup. Real and a clear bug. Why not HIGH: it only runs when the developer invokes steel keys sync; the file is in the project's own target/deploy, which cargo build-sbf regenerates, so in the common pre-deploy case the lost key is a throwaway; a program-id keypair is not an upgrade authority after first deploy; no attacker is involved. Irrecoverable loss is real only for a vanity or pre-announced program id kept only in target/deploy. MEDIUM. Shipped CLI. Upstream: one search (keys sync keypair) returned only PR #40 (the feature PR); no match found.

Upstream: No matching upstream report found (11 October 2026).

15. Medium as_account / as_account_mut return references after releasing the runtime borrow guard
First rating: Medium · Reviewed rating: Medium · Review: confirmed
Location: steel/lib/src/account/validation.rs:178 (return at 193; shared variant at 149/164)
From the report
Evidence
let mut data = self.try_borrow_mut_data()?;
...
T::try_from_bytes_mut(std::slice::from_raw_parts_mut(data.as_mut_ptr(), expected_len))
Why it matters

the borrow guard is dropped when the function returns, but the &mut T it returns lives as long as the AccountInfo. When a caller passes the same account twice (for example as both from and to), both loads succeed and the program holds two live mutable references to the same bytes. The runtime's duplicate-borrow protection is bypassed and the behaviour is undefined. A handler that reads both balances before writing them can then credit funds without debiting them. A close or realloc while such a reference is held leaves it dangling.

Suggested fix, not tested

return a guard (RefMut::map) that keeps the borrow alive, or provide and document a duplicate-account check that callers must run before loading.

Review: as_account_mut takes self.try_borrow_mut_data()? into a local data, builds a slice from data.as_mut_ptr(), and returns &mut T (validation.rs:178, 193); the RefMut is dropped at function exit while the returned reference is tied only to &self. In the Solana entrypoint a duplicated account is deserialized as a clone of the same Rc<RefCell<..>>, so a guard that was kept would reject the second load; this one does not, and the runtime does nothing else about duplicates. Passing the same account twice therefore yields two live &mut T over the same bytes: genuine aliasing UB from a safe-looking API, and the classic duplicate-mutable-account attack on handlers of the form "read both balances, then write both". Not blocked by ownership (the account is the program's own). Mitigated in practice because steel programs usually pin accounts with has_seeds / has_address and the framework's own sample never loads two of one type, and any handler can compare keys first, so it needs a specific handler pattern: MEDIUM, not HIGH. The claim about close/realloc leaving a "dangling" reference is overstated (realloc to 0 keeps the runtime buffer; the reference becomes stale, not freed). Shipped library, core API. Upstream: one search (as_account_mut borrow), no match found.

Upstream: No matching upstream report found (11 October 2026).

16. Medium replace_prog_id deletes everything after declare_id! and panics when the macro is missing
First rating: Medium · Reviewed rating: Medium · Review: confirmed
From the report
Evidence
let offset = contents.find("declare_id!").unwrap_or(contents.len());
let offset_start = offset + 11;
contents.replace_range(offset_start.., &formatted_key);
Why it matters

the replacement runs to the end of the file. Any code a developer has placed after declare_id! in api/src/lib.rs is deleted by keys new or keys sync. If the macro is absent, the index is out of range and the CLI panics, although the doc comment promises an error.

Suggested fix, not tested

replace only the declare_id!("…"); span, matched with a regex or syn, and return an error when it is not found.

Review: utils.rs:158-161: offset = find("declare_id!").unwrap_or(len), offset_start = offset + 11, then replace_range(offset_start.., ...) replaces everything to end of file with ("<key>");. Any code after the macro in api/src/lib.rs is deleted by keys new / keys sync, and a missing macro makes offset_start > len, so replace_range panics although the doc comment promises an Err. The template ends with declare_id!, so a fresh project is unaffected; the loss needs the developer to have added code below it. Borderline MEDIUM/LOW. Shipped CLI. Upstream: one search (declare_id replace) returned only PR #40 (the feature PR), no match found.

Upstream: No matching upstream report found (11 October 2026).

17. Medium steel build, steel test and steel clean report success when cargo fails
First rating: Medium · Reviewed rating: Medium · Review: confirmed
Location: steel/cli/src/build_project.rs:10 (line corrected on review; the report cites :6) (also test_project.rs:16, clean_project.rs:6)
From the report
Evidence
.status()
.expect("Failed to execute command");
Ok(())
Why it matters

the exit status of the child process is discarded. A failed compile or failing tests still exit 0, so a CI pipeline goes green and can merge or deploy a broken program.

Suggested fix, not tested

check status.success() and return an error carrying the child's exit code.

Review: All three call .status().expect("Failed to execute command") and then Ok(()) (build_project.rs:10-13, test_project.rs:30-33, clean_project.rs:44-47); expect only fires if cargo cannot be spawned, never on a non-zero exit. So steel test with failing tests and steel build with a compile error exit 0, and any CI that uses the CLI goes green. Real, silent, and the README advertises steel build / steel test as the workflow. Shipped CLI. Upstream: one search (exit status build), no match found.

Upstream: No matching upstream report found (11 October 2026).

Low after review (24)

1. Low Token-2022 accounts are accepted as token accounts without checking the account-type marker
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
if data.len() < spl_token_2022::state::Account::LEN {
...
let account = spl_token_2022::state::Account::unpack(std::slice::from_raw_parts(data.as_ptr(), spl_token_2022::state::Account::LEN))?;
Why it matters

any account owned by Token-2022 that is at least 165 bytes long is unpacked from its first 165 bytes, and the account-type byte at offset 165 is never read. Anyone can create a Token-2022 multisig account (355 bytes). Its signer slots hold bytes the creator chooses, and those bytes line up with the owner, amount, delegate and state fields of a token account. as_token_account then returns an owner, balance and delegate of the attacker's choosing. A program that does not pin the mint to a fixed value can be made to credit a balance that does not exist.

Suggested fix, not tested

for Token-2022, parse with StateWithExtensions::<Account>::unpack, which checks the account type. Reject multisig-length data.

Review: Real: the Token-2022 branch accepts len >= 165 and unpacks the first 165 bytes (validation.rs:86-97), never reading the account-type byte at offset 165, and the Token-2022 multisig layout (355 bytes, signer slots attacker-chosen) can be parsed as an Account. A hardening gap (use StateWithExtensions), not a practical hole for mint-pinned programs.

2. Low approve ignores the token program it is given and always targets the legacy token program
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
&spl_token::instruction::approve(
    &spl_token::ID,
Why it matters

every sibling wrapper builds its instruction for token_program.key. This one hard-codes the legacy program. With a Token-2022 account the instruction targets a program that is not in the account list, so the call fails. Program-id handling is inconsistent across the wrappers.

Suggested fix, not tested

build the instruction with token_program.key, and check that it is one of the two canonical token program ids.

Review: Real (cpi.rs:928-929), but the account list carries the caller's token_program, so with Token-2022 the CPI program account is missing and invoke fails; with the legacy program it works. Fails closed, one wrapper, no fund risk.

4. Low Account loaders panic on empty or short data instead of returning an error
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
if Self::discriminator().ne(&data[0]) {
...
bytemuck::try_from_bytes::<Self>(&data[8..])
Why it matters

data[0], data[8..] and split_at panic when the slice is too short. is_type panics the same way on a program-owned account with empty data. On chain, the transaction aborts with an opaque panic instead of InvalidAccountData. Off-chain clients and indexers that use these public functions on RPC data crash when an account is missing, because a missing account returns an empty buffer.

Suggested fix, not tested

check data.len() >= 8 + size_of::<T>() (or >= 1 for the type check) and return InvalidAccountData.

Review: Real for direct users of AccountDeserialize::try_from_bytes* and header traits (data[0], data[8..], split_at) and for is_type on an empty program-owned account (validation.rs:69; an attacker can create such an account with create_account). On chain a panic is just a failed transaction with a worse message; the common as_account* path checks the exact length first. Off-chain unwrap in the generated test is a client nuisance. No state corruption.

5. Low The program id is stored in two places and updated by two separate, duplicated write sequences
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
Location: steel/cli/src/program_keys.rs:47 (and line 94)
From the report
Evidence
replace_prog_id(new_key.insecure_clone().into())?;
Why it matters

keys new and keys sync both rewrite api/src/lib.rs and then the keypair file, as two independent steps. If the second write fails, the declared id and the keypair no longer match.

Suggested fix, not tested

route both commands through one function that writes both files to temporary files, renames them into place, and re-reads them to confirm they agree.

6. Low spl/cpi.rs is a 1,164-line file holding several unrelated groups of wrappers
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
pub fn create_associated_token_account<'info>(
...
pub fn get_account_data_size(
Why it matters

transfer, mint/burn, freeze/thaw, delegation, authority, multisig and native-SOL wrappers share one file as about 30 near-identical signed/unsigned pairs. Findings 20 and 21 are copy-paste defects of the kind this structure produces.

Suggested fix, not tested

split the file into one module per group, and generate the signed variants from a single helper.

7. Low The seed-plus-bump combining logic is duplicated
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
Location: steel/lib/src/account/cpi.rs:161 (same block at lines 29-34)
From the report
Evidence
let bump: &[u8] = &[bump];
let mut combined_seeds = Vec::with_capacity(seeds.len() + 1);
combined_seeds.extend_from_slice(seeds); combined_seeds.push(bump);
Why it matters

the same block appears twice, and the bump derivation is repeated in about 15 wrappers. The duplicates can drift apart.

Suggested fix, not tested

move both into one private helper.

8. Low Utility modules mix unrelated responsibilities
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
pub fn parse_instruction<'a, T: std::convert::TryFrom<u8>>(
...
pub fn string_to_bytes<const N: usize>(s: &str) -> Result<[u8; N], ProgramError> {
Why it matters

instruction dispatch and fixed-width string codecs sit in the same module. cli/src/utils.rs mixes stdin prompting, naming, manifest inspection and source rewriting. This makes ownership and review harder.

Suggested fix, not tested

split by concern, for example instruction.rs and string.rs, and naming.rs, project.rs and prog_id.rs in the CLI.

9. Low A broken or missing Solana CLI config silently falls back to defaults
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
} = load_config().unwrap_or_default();
let url = client_url.unwrap_or(json_rpc_url);
Why it matters

a malformed config file is swallowed. The RPC URL, commitment and keypair path quietly change to library defaults with no warning.

Suggested fix, not tested

fall back to defaults only when the file is not found. Report parse errors, and print the URL and signer actually in use.

11. Low steel keys new overwrites the program keypair with no confirmation, no backup and default permissions
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
let mut lib_rs = fs::OpenOptions::new()
    .create(true).write(true).truncate(true)
    .open(deploy_kp_path)?;
Why it matters

the keypair lives in target/deploy, which is git-ignored, so this is usually the only copy. It is truncated and replaced at once. The new secret key is written with the process umask, typically readable by other local users.

Suggested fix, not tested

require --force or a confirmation prompt, copy the old file to a backup first, write to a temporary file and rename it, and create the file with mode 0600.

Review: The help text for this subcommand is "Replace existing program keypair with new one" (args.rs:37), so replacing is the documented purpose; only the missing backup and the default file mode (open at program_keys.rs:54-58, no 0600) are real gaps, and the key sits in the user's own target/deploy.

12. Low The cargo-spawning wrapper is copy-pasted into three modules
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
Command::new("cargo")
    .arg("clean")
Why it matters

all three copies share the ignored-exit-status defect (finding 17), so the fix has to be made three times.

Suggested fix, not tested

add one run_cargo(args) helper that checks the exit status, and call it from all three commands.

13. Low Keypair-path derivation and the validity preamble are repeated in each keys command
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
Location: steel/cli/src/program_keys.rs:23 (also lines 52, 79)
From the report
Evidence
let deploy_kp_path = format!("./target/deploy/{}-keypair.json", formatted);
Why it matters

three copies of the same path logic and checks, plus two copies of the keypair write block, can drift apart.

Suggested fix, not tested

extract deploy_keypair_path(), require_built_project() and write_keypair_file() helpers.

18. Low Every CLI command loads a wallet signer it never uses
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
let (_url, _signer) = load_client_and_signer(url, commitment, keypair)?;
Why it matters

new, build, test, clean and keys all fail on a machine or CI runner without a Solana keypair file. With a usb:// or prompt:// signer they block on a hardware-wallet or seed prompt for nothing.

Suggested fix, not tested

remove the call, or make it lazily in the commands that actually sign.

Review: Real (main.rs:66 calls load_client_and_signer(...)? before every command, and load_signer fails if the default keypair path is missing; usb:// could prompt). Loud failure with an obvious workaround (--keypair); an annoyance for CI, not a correctness issue.

19. Low Signed token CPI helpers derive the signing PDA under the wrong program
First rating: Medium · Reviewed rating: Low · Review: rated too high
Location: steel/lib/src/spl/cpi.rs:141 (also 66, 226, 284, 336, 416, 496, 576, 654, 734, 814, 887; spl_token::ID at 954, 1030, 1096, 1133)
From the report
Evidence
let bump = Pubkey::find_program_address(seeds, authority_info.owner).1;
Why it matters

the runtime checks PDA signatures against the calling program. A typical vault authority PDA has no data and is owned by the System program, so the bump derived here does not match and every transfer_signed, mint_to_signed, burn_signed and similar call fails. The variants that use the token program id can never sign. The failure is closed, but PDA-authority token flows built on these helpers do not work.

Suggested fix, not tested

take the calling program id as a parameter, or require the *_with_bump variants with a stored bump.

Review: Real at cpi.rs:141 and 11 sibling sites; 954/1030/1096/1133 use spl_token::ID. invoke_signed derives signers under the calling program, so a mismatch makes the CPI fail (closed). It works when the authority PDA is owned by the calling program (the data-account case) and fails for system-owned vault PDAs; the _with_bump variants work. Broken ergonomics, not unsafe.

20. Low The approve wrappers pass wrong or swapped arguments and drop the checked semantics
First rating: Medium · Reviewed rating: Low · Review: rated too high
Location: steel/lib/src/spl/cpi.rs:1003 (also 975-981, 1030-1031, 1053-1063)
From the report
Evidence
&spl_token_2022::instruction::approve_checked(
    &token_program.key,
    mint_info.key,
    source_info.key,
Why it matters

approve_checked swaps source and mint. The signed variants pass the delegate as the owner and signer and leave required accounts out of the account list. approve_checked_signed calls the unchecked approve, so the decimals check its name promises never runs. These calls fail, or they run with the wrong authority semantics.

Suggested fix, not tested

follow the token program's parameter order (program, source, mint, delegate, owner, signers), take an explicit owner, and route the checked variant to the checked instruction.

Review: All are real and these four wrappers cannot succeed; they fail at the token program instead of doing something dangerous (owner mismatch, wrong account at position 0). Borderline MEDIUM for a broken public API, but loud at first use.

21. Low initialize_multisig leaves the multisig account out of the CPI
First rating: Medium · Reviewed rating: Low · Review: rated too high
Location: steel/lib/src/spl/cpi.rs:1077 (also 1109-1118)
From the report
Evidence
&spl_token_2022::instruction::initialize_multisig(&token_program.key, multisig_info.key, &[signer_info.key], n)?,
&[token_program.clone(), signer_info.clone()],
Why it matters

the instruction references an account that is not passed, so every call fails. The wrapper also accepts only one signer for an m-of-n setup.

Suggested fix, not tested

pass the multisig account and a slice of signers.

Review: Real (cpi.rs:1077-1081 builds the instruction with the multisig account but the account slice has only token_program and one signer; one signer for an m-of-n). Always fails; niche feature.

22. Low Token-2022 mints that have extensions are always rejected
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
if data.len() != spl_token_2022::state::Mint::LEN {
Why it matters

any mint with a transfer fee, metadata pointer, transfer hook or other extension is longer than 82 bytes. Programs built on as_mint cannot accept those tokens, and extensions are the main reason to use Token-2022.

Suggested fix, not tested

parse with StateWithExtensions::<Mint>::unpack.

Review: Real (data.len() != Mint::LEN, validation.rs:45), a feature gap that fails closed. The README does not claim extension support.

23. Low Fixed-point Numeric arithmetic is unchecked
First rating: Medium · Reviewed rating: Low · Review: rated too high
Location: steel/lib/src/numeric.rs:104 (also lines 37, 48, 65, 112, 120, 128)
From the report
Evidence
Numeric::from_i80f48(self.to_i80f48() + other.to_i80f48())
Why it matters

in release builds, with overflow checks off as is usual for Solana programs, add, subtract and multiply wrap silently. to_u64 turns negative or oversized values into wrong numbers. Division by zero aborts. A program doing price or reward math can store a plausible but wrong value.

Suggested fix, not tested

add checked_* operations and try_to_u64 / try_to_i64 that return a Result, and document the behaviour of the operator forms.

Review: That is ordinary Rust operator semantics, not a hidden trap, and the caller can use to_i80f48().checked_add since to_i80f48/from_i80f48 are public. Missing checked API, not a vulnerability in the library.

24. Low send moves lamports with unchecked arithmetic and cannot report an error
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
**self.lamports.borrow_mut() -= lamports;
**to.lamports.borrow_mut() += lamports;
Why it matters

an amount larger than the balance wraps the source to about 2^64 in release builds. The program keeps running on corrupt balances until the runtime rejects the transaction with a generic error. The function returns (), so it cannot signal insufficient funds.

Suggested fix, not tested

use checked_sub and checked_add, and return Result<(), ProgramError>.

Review: Real (lamports.rs:11-12, close.rs:10) but the runtime blocks the harmful outcome: an over-debit wraps the source up by 2^64, the instruction's lamport sum no longer balances and the transaction is rejected (unbalanced instruction). So worst case is a generic error and no theft; the missing Result is API design.

25. Low assert_mut* on mint and token-account views always panic
First rating: Medium · Reviewed rating: Low · Review: rated too high
Location: steel/lib/src/spl/validation.rs:163 (also 170-186, 233-256)
From the report
Evidence
fn assert_mut<F>(&mut self, _condition: F) -> Result<&mut Self, ProgramError>
...
    panic!("not implemented")
Why it matters

these public trait methods compile but abort the program at runtime with no meaningful error.

Suggested fix, not tested

return an error, or split the trait so these types do not offer the mutable methods.

Review: Real (validation.rs:163-186, 233-256 all panic!("not implemented")), but those types are returned by value, not views into the account, so a mutable assert makes no sense and nothing in the repo calls it. A call fails the transaction; found at first test.

26. Low bytes_to_string silently alters invalid UTF-8 and never returns its documented error
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
Ok(String::from_utf8_lossy(&bytes[..actual_len])
    .trim_matches('\0')
    .to_string())
Why it matters

the doc comment promises an error for invalid UTF-8. Instead, invalid bytes are replaced, so two different stored names can read back as the same string. string_to_bytes also accepts embedded NUL bytes, which truncate the value on the way back.

Suggested fix, not tested

use String::from_utf8 and map the failure to the documented error, and reject interior NULs when encoding.

Review: Real doc/behaviour mismatch (utils.rs:56-63 uses from_utf8_lossy; ERROR_INVALID_UTF8 is never used) and string_to_bytes accepts NUL. Inputs written through string_to_bytes(&str) are valid UTF-8, so the lossy path is only reachable with externally-written bytes; collisions limited to names.

27. Low The event macro generates only a panicking decoder
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
pub fn from_bytes(data: &[u8]) -> &Self {
    bytemuck::from_bytes::<Self>(data)
}
Why it matters

indexers that decode event bytes from transaction logs crash on truncated or misaligned data. The instruction macro already generates a fallible decoder.

Suggested fix, not tested

generate a try_from_bytes that returns a Result.

Review: Real (macros.rs:12-21 uses bytemuck::from_bytes, which panics), but off-chain, opt-in, and named like bytemuck's own panicking function; the account and instruction macros do have fallible forms.

28. Low Project detection uses substring matching to gate destructive commands
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
dirs.iter().any(|dir| path_str.contains(dir)) // fixme: `contains` also matches partial words
Why it matters

directories such as apis, rapid or programs count as matches. A real project with an extra matching folder is rejected, and a non-project can pass. This check is the only guard before the keypair and source rewrites.

Suggested fix, not tested

compare each entry's file_name() exactly.

Review: Real and already flagged by the author's own FIXME (utils.rs:137). The check is a second guard behind the Cargo.toml steel dependency check, and the commands it gates are keys list/new/sync; misclassification is a false reject or accept of a directory shape, not a data-loss path by itself.

29. Low The generated sample program adds an instruction-supplied amount without overflow checking
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
let amount = u64::from_le_bytes(args.amount);
...
counter.value += amount;
Why it matters

the value < 100 guard runs before the addition, so a large amount wraps the counter in release builds. Every project generated by steel new starts from this pattern.

Suggested fix, not tested

use checked_add and apply the bound to the result.

Review: Template (program_src_add_rs:6-19): value < 100 is checked before value += amount with an instruction-supplied u64, so in a release build the counter can wrap. It is a demo counter and the pattern is the generated sample, not shipped library code; worth a checked_add as a teaching example.

30. Low Generated projects depend on an older major version of the library
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
steel = "3.0"
Why it matters

the workspace ships version 4.0.9, but steel new creates projects pinned to 3.x. The scaffold, the documentation and the CLI drift away from the library the generated code compiles against.

Suggested fix, not tested

substitute the CLI's own package version into the template at build time, and add a CI job that generates a project and builds it.

Review: Fact is right (cargo_toml:20 vs workspace version 4.0.9, Cargo.lock confirms 4.0.9), but "3.0" resolves to the latest published 3.x release, no break is shown, and whether the 4.x API is needed by the generated code cannot be checked offline. Maintenance drift.

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.