| Audited | regolith-labs/ore at commit 48c203bd75db3cc45105ec29d8f8db719e5a2263 regolith-labs/entropy at commit f26ae03cccab6188effb0a170b8123cf4bb54c94 regolith-labs/ore-stake at commit 7628af733d9338e9560b353ef731a66efe9db3c8 |
|---|---|
| Date | 11 October 2026 |
| How it ran | local run on our workstation, 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)
entropy/program/src/sample.rs:14From the report
.assert_mut(|v| clock.slot >= v.end_at)?;
...
let hash = solana_program::keccak::hashv(&[&var.end_at.to_le_bytes()]);
- Why it matters: sample is permissionless and accepts the slot
end_atitself. During that slot, the slot-hash history cannot yet containend_at. The same happens if the slot was skipped or sampling is more than 512 slots late. In all three cases the stored hash becomeskeccak(end_at), which anyone can compute from round start, and it is write-once. - Impact: the final value then depends only on the provider's seed. Whoever holds the seed knows the winning square and the top-miner ticket while deploys are still open. Your own CLI computes the outcome this way.
- Who can exploit it: this needs the seed holder, which is why we rate it MEDIUM rather than HIGH, but it undermines the fairness guarantee.
- Fix: require
clock.slot > end_at. Never substitute a derivable value: leave the variable unsampled and use a refund or re-target path.
Review: process_sample accepts clock.slot >= v.end_at (sample.rs:14) and, when slot_hashes.get(&var.end_at) has no entry, stores keccak(end_at) as the slot hash (sample.rs:35). SlotHashes does not contain the current slot's own hash, so a sample landing exactly in slot end_at, a skipped end_at slot (normal on Solana), or a sample more than 512 slots late all take the fallback; the field is then write-once (if var.slot_hash != [0;32] { return Ok(()) }). The final value is keccak(slot_hash, seed, samples) (var.rs finalize), so in the fallback case it depends only on the provider's seed, which the provider holds in advance as a precomputed hash chain. That contradicts the entropy README's own guarantee that "the slothash sampled at the ending slot is unknown to the Entropy API". Who can exploit: only the seed holder (provider), who can also trigger it by landing a sample in slot end_at (permissionless signer-only instruction), or simply bet on the fallback outcome for the case of a skipped slot. The ore CLI itself computes the winner from keccak(end_at) (ore/cli/src/main.rs:442), which shows the fallback is part of the operators' flow, not just a corner case. The MEDIUM (not HIGH) rating is fair because it needs the trusted provider. Shipped on-chain (entropy). Upstream: 1 search (repo:regolith-labs/entropy sample slot hash), total_count 0.
Upstream: No matching upstream report found (11 October 2026).
ore/program/src/reset.rs:84From the report
.assert(|v| v.slot_hash != [0; 32])?
.assert(|v| v.seed != [0; 32])?
.assert(|v| v.value != [0; 32])?;
- Why it matters: reset is the only way to advance the round, and it needs the provider's seed. While the round is not reset, checkpoint refuses the current round and close refuses it too, so all SOL deployed in the round stays locked.
- Provider's options: after the slot hash is sampled, the provider can compute the outcome and choose not to reveal it. If the seed is lost, the lock is permanent: there is no timeout and no way to rotate the variable (finding 3).
- Fix: add a reveal deadline after which anyone can trigger a refund (the existing "no randomness" refund branch), and document the trust assumption.
Review: Reset requires v.seed != 0 and v.value != 0 (reset.rs:82-84); only a seed whose keccak equals commit passes Var::finalize, and only the off-chain provider has it. While reset has not run, checkpoint returns early for the current round (round.id == board.round_id, checkpoint.rs:48) and close requires r.id < board.round_id (close.rs:21), so all SOL in that round is stuck. Nothing on chain gives a timeout, and the entropy Close is authority-only with the authority being the board PDA, which no ore instruction can sign for. Mitigations that keep it borderline: the entropy README states the trust assumption (provider keeps seeds secret and reveals), a plain outage only delays rather than loses funds, and the program upgrade authority is a Squads multisig (DEPLOY.md:19, 27), so an upgrade could add a deadline. The report's suggested fix relies on a refund branch that is unreachable (see header). Shipped on-chain. Upstream: 1 search (repo:regolith-labs/ore reveal entropy provider), total_count 0.
Upstream: No matching upstream report found (11 October 2026).
entropy/program/src/next.rs:22From the report
.assert_mut_msg(|v| v.samples > 0, "No samples remaining")?;
...
var.samples -= 1;
- Why it matters: each round's first deploy calls
next, which decrementssamples. When it goes from 1 to 0, players can still deploy into that round. Reveal then always fails, because validation rejectssamples == 0(var.rs:75), so that round's SOL is locked and no new round can start. The CLI uses a budget of about 1 million samples, and there is no rotation path. - Fix: refuse to start a round unless more than one sample remains, make the last sample finalisable, add a rotation path, and alert well before the budget runs out.
Review: Traced: next requires samples > 0 (next.rs:22) and then does var.samples -= 1 (next.rs:35). Ore calls next on the first deploy of every round (deploy.rs:63-68). On the call that takes samples from 1 to 0, that round starts and accepts deploys, but Var::is_valid returns false when samples == 0 (var.rs:75), so reveal fails ("Invalid seed"), reset can never run, and that round's SOL is stuck (same lock path as #39). It is a deterministic off-by-one that happens exactly when the budget is used up. Not supported: the "about 1 million samples" figure (the budget is the SAMPLES env var at var creation, ore/cli/src/main.rs:245) and the deployed remaining budget is unknown (the report says so in "not covered"). Operators can see it coming and the multisig can upgrade, hence borderline. Shipped on-chain (entropy). Upstream: 1 search (repo:regolith-labs/entropy samples), total_count 0.
Upstream: No matching upstream report found (11 October 2026).
Low after review (46)
ore/program/src/deploy.rs:82From the report
if treasury.motherlode > max_motherlode || treasury.motherlode < min_motherlode {
return Ok(());
}
- Why it matters:
AutomationConditions.max_production_costis documented as "Deploy blocked if EMA exceeds this". Reset maintainsboard.production_cost_emaevery round, but no instruction ever compares the two (max_production_costis only defined and serialised). A user who sets a cost ceiling keeps having SOL deployed by their automation after the ceiling is exceeded. - Fix: add
board.production_cost_ema > automation.conditions.max_production_costto the same gate. Better, put all condition checks in oneconditions_met()method on the automation and call that.
Review: Confirmed: max_production_cost appears only in automation.rs (field, default, to/from bytes); production_cost_ema is only written in reset.rs:245 and printed by the CLI; deploy.rs:80-84 checks only the two motherlode bounds. But the owner's own deposit and per-round amount still cap the spend, so a stop condition that does nothing means the automation behaves as if the condition was not set. Silent but bounded, not a loss path.
From the report
// let new_admin = Pubkey::new_from_array(args.admin);
...
config.protocol.round_slots = new_round_slots;
- Why it matters: the instruction and SDK carry admin, fee_collector, fee_rate, entropy_var_address and entropy_program_id. The handler drops all five and still returns success, so an operator rotating the admin, fee collector or entropy variable gets a successful transaction that changes nothing. The two values it does apply are unbounded:
round_slots = 0makes rounds impossible.- A huge
intermission_slotscan push reset past the round's fixed expiry, after which miners forfeit their returned SOL. - Fix: either apply and validate every field, or remove the dead fields and reject non-zero values. Add minimum and maximum bounds for both slot settings, and make expiry relative to the actual reset slot.
Review: Confirmed: reset of new_admin, fee_collector, fee_rate, entropy_var_address, entropy_program_id is commented out (update_protocol_config.rs:8-14); only slots are applied (:27-28). The instruction is gated to ADMIN_ADDRESS (:20), so zero or huge slot values are admin mistakes, not an attack; the dead fields are an operator-tool wart.
entropy/program/src/lib.rs:25From the report
// EntropyInstruction::Open => process_open(accounts, data)?,
- Why it matters: ore's
NewVar(new_var.rs:32) calls entropy's Open over CPI, but the entropy dispatcher has Open commented out and rejects it.open.rsis unreachable. WithVAR_ADDRESShard-coded in ore, there is no way to rotate or replace the entropy variable, which makes findings 39 and 40 unrecoverable on-chain. - Fix: re-enable Open with proper authority checks, or remove
NewVar,open.rsand the SDK/CLI paths. Read the variable address from config rather than a constant.
Review: Confirmed: EntropyInstruction::Open is commented out (entropy lib.rs:25) and falls into the _ => rejection, so ore's NewVar CPI (new_var.rs:32) always fails; VAR_ADDRESS is also a hard-coded constant (consts.rs:104), so rotation would need a program upgrade anyway. Admin-gated (config.protocol.authority), no user impact by itself; it matters only as the missing escape hatch for #39/#40, which are rated there.
ore/program/src/deploy.rs:76From the report
.assert_mut(|a| a.executor == *signer_info.key || a.executor == EXECUTOR_ADDRESS)?
- Why it matters: the rule "discretionary strategies must not use the public executor" exists only in
automate.rs. Deploy accepts any signer when the executor is the public address, whatever the strategy. Any automation account created before that check, or by a future path, would let anyone choose the amount and squares for the owner's funds. - Fix: repeat the strategy/executor check in deploy.
ore/program/src/reset.rs:28From the report
.assert_mut(|b| clock.slot >= b.end_slot + config.protocol.intermission_slots)?;
- Why it matters: after reset,
end_slot = u64::MAXmeans "waiting for first deploy". Only the overflow panic ofu64::MAX + nstops a second reset on an unstarted round. Built without overflow checks, the sum would wrap and allow repeated resets that mint each time. - Fix: add an explicit
end_slot != u64::MAXcheck, or usechecked_add.
ore-stake/api/src/state/vesting.rs:30From the report
if treasury.total_staked > 0 {
treasury.rewards_factor += Numeric::from_fraction(amount, treasury.total_staked);
}
- Why it matters:
vested_amountadvances regardless, so ORE that vests while total stake is zero stays in the treasury token account with no ledger entry. The unit test calls this "by design", but the tokens become permanently unreachable. - Fix: keep the remainder pending until someone is staked, or add a sweep, and reconcile treasury tokens against the ledger.
ore/api/src/state/miner.rs:98From the report
self.lifetime_rewards_ore -= fee;
- Why it matters:
rewards_ore,refined_ore,lifetime_rewards_ore,total_unclaimed,total_refinedand the rewards factor are each mutated inline in checkpoint,claim_oreandupdate_rewards. Lifetime rewards are gross at checkpoint and net at claim, which makes the ledger hard to reason about. - Fix: move accrual and claim into one method that updates miner and treasury together, and document what each field means.
ore/program/src/checkpoint.rs:121From the report
round.top_miner = miner.authority;
- Why it matters:
Round.top_mineris written in reset (verified winner, or the split sentinel) and again in checkpoint. It holds three kinds of value: a pubkey, the split sentinel or the default key. - Fix: make reset the single writer and have checkpoint only read the field.
ore/api/src/state/automation.rs:62 (and the Default impl at line 94)From the report
/// Default: u16::MAX (no preference).
...
split_tiles: 0,
- Why it matters: the doc comments say u16::MAX means "no preference", but the code treats 0 as "no preference" and any value above 0 as a request. An integrator following the docs sends 65535. Deploy then selects every solo and every split tile, which is all 25 squares, so the automation spends up to 25 times the intended amount per round.
- Fix: pick one sentinel, clamp
solo_tiles + split_tilesto 25 or fewer in automate, and correct the docs (themax_motherlodedoc also says u64::MAX for a u16 field).
Review: Confirmed doc/code mismatch: doc says "Default: u16::MAX (no preference)" (automation.rs:62-67), but Default is 0 (:94-95) and deploy treats any value above 0 as a request (deploy.rs:114-115, 175-176), so 65535 selects all squares of each kind. Triggered only by an integrator following the wrong doc; the amount per square and the deposit are the owner's own, a closed automation cannot overspend (deploy.rs:268-277 closes it when the balance is short). A documentation fix, not a protocol defect.
ore/program/src/reset.rs:204From the report
panic!("Top miner verification failed");
...
panic!("Top miner account cannot be parsed");
- Why it matters: the comment above this block says "dry-run - no errors on failure". The 2026-01-13 change record requires "No panics or transaction failures from this verification". The code panics in three places, so round settlement depends on an off-chain computation being exact.
- Effect on your own CLI: the CLI's reset command computes the winner from a substituted slot hash (
ore/cli/src/main.rs:442). Whenever the real slot hash is still available, that winner is wrong and reset fails. - Fix: either log as specified, or make enforcement an explicit, documented step that returns a typed error. In both cases make the CLI read the actual sampled hash.
Review: The panics are real (reset.rs:204, 207, 210) and contradict the in-code comment "dry-run - no errors on failure" and the changelog pseudocode, but the same changelog states "Enforcement phase: Will be enabled via new program deployment" (changelog:94), so the strict version is the intended later phase (the report's own #37 says as much). Reset stays permissionless and the correct top miner is computable from public Miner accounts (cumulative ranges are disjoint), so a wrong account only reverts a tx and anyone can retry with the right one. The CLI half is true: reset overwrites var.slot_hash with keccak(end_at) (ore/cli/src/main.rs:442) before computing the winner, which differs from the real hash whenever the end slot is in the sysvar, so the CLI's own reset fails in that case. Operator-tool bug, LOW.
ore/api/src/consts.rs:26From the report
pub const ONE_MINUTE_SLOTS: u64 = 200;
- Why it matters: at the roughly 0.4 s slot time the CLI itself assumes, 200 slots is about 80 seconds. The "one day" round expiry is then about 32 hours and the "12 hour" bot window about 16 hours, and both drift with network slot time. The neighbouring doc comments are also attached to the wrong constants.
- Fix: document the slot-time assumption next to the constant, or base the windows on timestamps.
ore/program/src/checkpoint.rs:55From the report
if clock.slot >= round.expires_at {
miner.checkpoint_id = miner.round_id;
return Ok(());
- Why it matters: a miner gets back roughly 89 to 99 percent of what they deployed, but only if someone checkpoints them before expiry. After that the claim is zeroed and
closesweeps the leftover SOL to the treasury. The checkpoint bot fee is the only safeguard. This is documented design, but the downside falls on users who stop playing. - Fix: settle lazily on the miner's next action without expiry, or send forfeited SOL to a recoverable pool.
ore/cli/src/main.rs:43 (line corrected on review; the report cites :36)From the report
match std::env::var("COMMAND").expect("Missing COMMAND env var").as_str() {
- Why it matters: swap, reset, maintenance, reporting and RPC helpers all live in one file, so changes are hard to review.
- Fix: split it into modules.
ore/program/src/checkpoint.rs:95From the report
let sq_admin = (sq_total / 100).max(1);
- Why it matters: the same admin and protocol fee arithmetic appears in
checkpoint.rs(twice), inRound::calculate_feesand twice in the CLI. One CLI copy already uses a different formula. TheADMIN_FEEconstant is unused. - Fix: add one helper in the API crate and call it from reset, checkpoint and the CLI.
ore/cli/src/main.rs:1495From the report
async fn submit_transaction(rpc: &RpcClient, payer: &...Keypair, instructions: &[...Instruction])
- Why it matters: the submit, simulate and program-account helpers, including a fixed priority fee, exist in all three CLIs, so a policy change needs three edits.
- Fix: move them into a shared crate.
ore/program/src/wrap.rs:11From the report
const LIQ_PCT: u64 = 15;
const LIQ_MANAGER: Pubkey = pubkey!("Ag3Ak...");
- Why it matters: fee shares, the liquidity split, the EMA window and the 100 SOL wrap cap are literals spread across handlers, and the CLI repeats some of them. Config fields for fee rate and fee collector exist but are never read.
- Fix: keep these values in one place, either the constants module or Config, and have clients import them.
entropy/cli/src/main.rs:115From the report
let response = reqwest::get(&url).await?;
let seed_response: ...GetSeedResponse = response.json().await?;
- Why it matters: a hung or hostile endpoint can stall the operator tool or exhaust its memory. The on-chain commit check keeps the value itself safe.
- Fix: use a client with a timeout and a capped body size.
ore/api/src/event.rs:34From the report
pub total_miners: u64,
- Why it matters: the IDL names the same slot
num_winners, andtotal_winningsappears astotal_returned_sol. Events carry no version field, so consumers that parse by name or position will misread them. - Fix: add a version, only append new fields, and keep the IDL in sync.
ore/program/src/automate.rs:11From the report
} else if let Ok(args) = Automate::try_from_bytes(data) {
- Why it matters: the V1 automate fallback, the
Liqstub, the undispatched V2 enum and the unused admin config block are compatibility residue. Nothing records why they exist or when they can go. - Fix: record each item with its removal condition.
ore/Cargo.toml:22From the report
entropy-api = "0.1.4"
- Why it matters: ore reads the entropy variable account through the published 0.1.4 layout, while the entropy source in this submission is 0.1.6. Compatibility is assumed, not tested.
- Fix: add a layout test (size, offsets, discriminator) or depend on the workspace version.
ore/program/src/reset.rs:7From the report
// TODO Integrate admin fee
- Why it matters: the admin fee is already paid at line 259, so the TODO is stale.
buyback.rs:102disables a balance assertion with no stated reason. - Fix: resolve or delete each item, or write down the open question and why it was deferred.
ore/cli/src/main.rs:1158From the report
// Find insolvent rounds: group uncheckpointed miners by round.
- Why it matters: the CLI shows that rounds have been under-funded before, but there is no recorded cause and no on-chain solvency check.
- Fix: document the failure mode and assert round solvency in reset (see finding 42).
From the report
signer_info.is_signer()?.has_address(&ADMIN_ADDRESS)?;
- Why it matters: admin actions are gated by
ADMIN_ADDRESS, byconfig.protocol.authority(new_var) and byBURY_AUTHORITY(buyback, wrap). Meanwhile several config fields are never read, which makes it unclear who controls what. - Fix: pick one authoritative source.
ore/cli/src/main.rs:433From the report
pub const ORE_VAR_ADDRESS: Pubkey = pubkey!("BWCaDY96Xe4WkFq1M7UiCCRcChsJ3p51L5KrGzhxgm2E");
- Why it matters: the program constant, this CLI literal and the SDK's derivation (
var_pda(board, 0)) can drift apart unnoticed. - Fix: export one constant and add a unit test that it equals the derived address.
ore-stake/program/src/log.rs:5From the report
pub fn process_log(accounts: &[AccountInfo<'_>], _data: &[u8]) -> ProgramResult {
- Why it matters: the two handlers differ only in the expected signer.
- Fix: optional. A small shared helper would do.
ore-stake/localnet.sh:1From the report
solana-test-validator -r ... --bpf-program oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv target/deploy/ore.so ...
- Why it matters: the script is a copy of ore's and does not load the staking program, so the staking program cannot be exercised locally.
- Fix: write a script specific to ore-stake.
ore/cli/src/main.rs:343From the report
let liq_amount = total_amount * 0 / 100;
- Why it matters: the comment says 10 percent, the code uses 0 and the program uses 15. The caps also differ: 10 SOL in the CLI, 100 SOL on-chain. The quoted swap size therefore does not match what wrap moves.
- Fix: import the program's constants.
entropy/Cargo.toml:1From the report
[workspace]
resolver = "2"
members = ["api", "program"]
- Why it matters: unlike ore, entropy declares no release profile with overflow checks, and it pins a different toolchain.
- Fix: align the profiles and toolchains, or record why they differ.
ore/README.md:45 (line corrected on review; the report cites :42)From the report
To run the test suite, use the Solana toolchain: cargo test-sbf
- Why it matters: we searched for test folders and test attributes. Unit tests exist only in
round.rs,stake.rsandvesting.rs. Nothing covers deploy, reset, checkpoint, claim, bury, buyback or any entropy instruction, which is where most findings in this report sit. - Fix: add program-level integration tests for the full deploy, reset, checkpoint and claim cycle, including a solvency invariant.
ore/cli/src/main.rs:1253 (line corrected on review; the report cites :1252)From the report
let ix = solana_sdk::system_instruction::transfer(&payer.pubkey(), round_pda, *amount);
- Why it matters:
topup_roundssends operator SOL to under-funded rounds with no confirmation prompt, but the cause of the under-funding is not fixed in the program. - Fix: fix the cause (finding 42), and make the tool dry-run by default with an explicit confirm flag.
ore/docs/DEPLOY.md:181From the report
solana program set-buffer-authority "$BUFFER_ADDRESS" --new-buffer-authority "$MULTISIG_AUTHORITY" -um
- Why it matters: nothing compares the on-chain program hash with the local build after the multisig executes, and there is no rollback section. The same applies to
entropy/DEPLOY.md. - Fix: verify the buffer again after the authority transfer, check the program hash after the upgrade, and document a rollback.
ore/docs/DEPLOY.md:138From the report
solana-verify export-pda-tx "$GITHUB_REPO" --library-name "$LIBRARY_NAME" --program-id "$PROGRAM_ID" ...
- Why it matters: the verification transaction is not pinned to the commit that was built, and the build's hash and size are never printed.
- Fix: pass the commit hash, print the executable hash, and require a clean working tree.
ore/docs/DEPLOY.md:93From the report
solana-keygen new -o "$BUFFER_KEYPAIR" --no-bip39-passphrase --force
- Why it matters:
--forcesilently overwrites an existing keypair in a shared temporary directory. The same line is inentropy/DEPLOY.md. - Fix: create a private temporary directory, or check that the file does not exist and drop
--force.
ore/program/src/deploy.rs:15From the report
sol_log(&format!("Ore accounts: {:?}", ore_accounts.len()).to_string());
- Why it matters: lines that print on every deploy and reset carry no information, cost compute units and bury the warnings that do matter.
- Fix: remove them and keep only distinct warning messages.
From the report
**Enforcement phase**: Will be enabled via new program deployment (not a config flag)
- Why it matters: enforcement is already live (see finding 10). The README lists Initialize, SetAdmin, SetFeeCollector and SetFeeRate, none of which exist, and omits Buyback, NewVar, UpdateProtocolConfig and Close. The entropy README lists Open as live.
- Fix: update the docs to match the shipped version.
ore/program/src/reset.rs:161From the report
if round.did_hit_motherlode(r) {
round.motherlode = treasury.motherlode;
treasury.motherlode = 0;
- Why it matters: reset always mints up to 1.2 ORE and may move the whole motherlode into the round. Checkpoint pays these only to miners on the winning square. When that square is empty, the minted ORE and the motherlode sit in the treasury with no ledger entry, cannot be claimed, and permanently use up supply headroom.
- Made worse by: zero-amount deploys are accepted, so empty rounds cost almost nothing to create.
- Fix: skip the mint and leave the motherlode in the treasury when the winning square is empty, and reject zero-amount or empty-mask deploys.
Review: Mechanism confirmed: reset always mints mint_amount (up to 1 ORE) into the treasury token account and, on a motherlode hit, moves treasury.motherlode into the round (reset.rs:147-186); checkpoint pays them only to miners on the winning square (checkpoint.rs:86-146); bury burns only what its signer transfers in (bury.rs:36-74) and the buyback burns only the swap delta, so the stranded ORE has no exit. But it requires the winning square to be empty (1 in 25 squares per round; practically only in near-empty rounds), the base loss is at most 1 ORE per such round, and a jackpot is lost only when a 1/500 hit coincides with an empty square. Stranded supply, no user funds, no theft. LOW (fix is easy: skip the mint when deployed[winning_square] == 0).
ore/api/src/state/round.rs:91From the report
let sq_admin = ((deployed / 100) as u64).max(1);
...
protocol_fee += ((deployed.saturating_sub(sq_admin) / 10) as u64).max(1);
- Why it matters: a square holding 1 lamport is charged 2 lamports in fees. Deploy has no minimum amount, and the defined
AmountTooSmallerror is never raised. In a round dominated by dust, reset tries to send more than the round holds above its rent reserve and reverts until someone donates the shortfall. In mixed rounds, the round can end up short for the last miners to checkpoint, which matches the top-up tooling in finding 32. - Fix: cap each square's fees at its deployed amount, enforce a minimum deploy amount, and assert round solvency after fees.
Review: Real arithmetic issue but small: a 1-lamport non-winning square is charged admin 1 + protocol 1 = 2 (round.rs:91-94), the only case where fees exceed the square. Checkpoint's per-square payout (sq_total - sq_admin - sq_protocol, saturating, checkpoint.rs:95-97, 150-153) and reset's fees are otherwise exact complements, so the total shortfall is at most one lamport per 1-lamport square (at most 24 lamports per round) and is drawn from the round's rent reserve, not from the last miners as the report says. Reset can revert only for a round made almost entirely of dust (round balance would drop below rent-exempt), and anyone can fix it by donating a few lamports to the round PDA. An unused AmountTooSmall error confirms a minimum was intended. LOW.
ore/api/src/state/miner.rs:79From the report
let claim_refined = (self.refined_ore * bps) / DENOMINATOR_BPS;
let claim_rewards = (self.rewards_ore * bps) / DENOMINATOR_BPS;
- Why it matters: overflow checks are on, so any miner holding more than about 18,446 ORE unclaimed panics on a full claim (bps = 10,000, the default). They can only claim in smaller slices.
- Fix: compute in 128-bit:
((x as u128 * bps as u128) / 10_000) as u64.
Review: Arithmetic confirmed: refined_ore * bps and rewards_ore * bps are plain u64 (miner.rs:79-80) with overflow-checks = true (ore/Cargo.toml [profile.release]), so a full claim (bps 10000) panics above about 1.84e15 raw units = 18,446 ORE (TOKEN_DECIMALS = 11, consts.rs:8). But emissions are about 1.2 ORE per round across all miners, so one account holding 18k unclaimed ORE is essentially unreachable, and any partial claim (bps < 10000) works as a workaround; no funds are lost. LOW.
ore/program/src/buyback.rs:103From the report
// assert_eq!(post_swap_sol_balance, 0);
assert!(post_swap_ore_balance >= pre_swap_ore_balance);
- Why it matters: the buyback key supplies the whole swap route and data, and the treasury signs. The only checks are that lamports, supply and the ORE balance did not fall, so a route that spends all treasury wSOL for zero ORE passes. That route could come from sandwiching or from a compromised hot key. Slippage protection exists only off-chain.
- Fix: enforce a minimum ORE output and a maximum wSOL spend on-chain, pin the destination account, and put the authority behind a multisig.
Review: The checks (lamports, supply, ORE balance not lower; buyback.rs:178-203) are indeed thin, and BURY_AUTHORITY supplies route and data while the treasury signs, so a compromised hot key could route treasury wSOL out through the swap program. But: it requires that key (has_address(&BURY_AUTHORITY), buyback.rs:116), the swap program is pinned (SWAP_PROGRAM = Jupiter v6, consts.rs:101), and the min-out in a Jupiter route is enforced on-chain by Jupiter from the instruction data, so "slippage protection exists only off-chain" is not accurate (the value is operator-chosen). Defense-in-depth gap against key compromise only. LOW.
ore/program/src/buyback.rs:115 (line corrected on review; the report cites :116)From the report
if shared_amount > 0 {
invoke_signed(
&ore_stake_api::sdk::distribute(*treasury_info.key, shared_amount),
- Why it matters: the staking program rejects distribute when total stake is zero (
distribute.rs:30), and the error reverts the whole buyback or bury. If every staker withdraws, buybacks stop until someone stakes again. - Fix: skip or burn the staker share when nobody is staked.
Review: Confirmed: ore-stake distribute errors with NoDeposits when total_staked == 0 (distribute.rs:30), and buyback/bury call it for amount / 10 (buyback.rs:114-123, bury.rs:44-59), reverting the whole instruction. Buyback is operator-only (BURY_AUTHORITY) and bury is a voluntary burn by the caller; the operator can stake a dust amount to resume. A liveness wrinkle, no funds at risk. LOW.
entropy/api/src/sdk.rs:8From the report
accounts: vec![AccountMeta::new(signer, true), AccountMeta::new(var, false)],
- Why it matters: the handler expects three accounts (
close.rs:6), so every close built with the SDK or CLI fails. - Fix: add the system program account, or drop it from the handler.
Review: Confirmed: sdk::close builds 2 accounts (sdk.rs:8) while the handler destructures 3 (close.rs:6), so SDK-built closes fail with NotEnoughAccountKeys. The var's authority is the board PDA, which no ore instruction signs for, so this SDK helper is only useful for third-party vars; fails loudly, funds never move. LOW.
ore/cli/src/main.rs:577From the report
ore_api::sdk::deploy(payer.pubkey(), payer.pubkey(), board.round_id, amount, squares);
- Why it matters: the SDK takes amount first, then round id. The command derives the round address from the amount, so it always fails. No funds move, but the command is unusable.
- Fix: swap the arguments. Consider distinct types for the two values.
Review: Confirmed: sdk::deploy(signer, authority, amount, round_id, squares) (sdk.rs:110-116) is called as deploy(payer, payer, board.round_id, amount, squares) (main.rs:577), so the round PDA is derived from the amount and the program's seed check rejects it. Repo CLI command, loud failure, no funds moved. LOW.
ore/cli/src/main.rs:319From the report
spl_token::instruction::transfer(&spl_token::ID, &authority, &recipient, &authority, &[&authority],
miner.rewards_ore + miner.refined_ore,
- Why it matters: the source is the wallet address, not its token account, and the amount ignores the 10 percent refining fee. Setting RECIPIENT therefore makes the whole claim transaction fail.
- Fix: use the associated token accounts and the net claimed amount.
Review: Confirmed: with RECIPIENT set, the CLI appends spl_token::instruction::transfer(.., source = authority wallet address, ..) with the gross amount (main.rs:319-326); the source must be a token account, so the transaction fails. The default path (no RECIPIENT) works. Repo CLI, fail-safe. LOW.
ore/cli/src/main.rs:1399From the report
let filter = RpcFilterType::Memcmp(Memcmp::new_base58_encoded(512, &round_id.to_le_bytes()));
- Why it matters: since the
massfield was added,round_idsits at byte 664, and line 520 already uses that offset. The participating-miners command matches the wrong bytes and returns wrong or empty results. - Fix: derive filter offsets from the struct in one shared helper.
Review: Confirmed: get_miners_participating filters at byte 512 (main.rs:1399) while the correct offset used at main.rs:520 is 664 (8 discriminator + 32 + 3*8 + 3*200 = 664). The participating_miners command returns wrong or empty results. Read-only report command. LOW.
ore/api/idl.json:477From the report
"name": "bury",
"discriminant": { "type": "u8", "value": 13 }
- Why it matters: in the program, 13 is Buyback and Bury is 24. The IDL also lists
setAdminat 15 (UpdateProtocolConfig in the code) andnewVarat 17 (19 in the code). It also omits automation fields and checkpoint and deploy accounts. A client generated from it calls the wrong instructions or decodes accounts incorrectly. - Fix: generate the IDL from source in CI and fail the build on any difference.
Review: Confirmed with detail: idl.json lists bury at 13 while the code has Buyback = 13, Bury = 24, UpdateProtocolConfig = 15 (IDL: setAdmin), NewVar = 19 (IDL: 17) (instruction.rs:5-23); IDL checkpoint has 6 accounts vs 8 in the program; IDL deploy has 11 vs 12 (no treasury); IDL Automation lacks total_sol_spent, total_ore_earned, conditions. A client generated from this IDL gets wrong accounts and fails (seed and key checks reject them); the instructions that would be mis-dispatched (13, 15) are admin-gated. Integrator-facing staleness, not a fund risk. LOW.
ore/Cargo.toml:3From the report
members = ["api", "program"]
- Why it matters:
ore/cli/Cargo.tomlusesversion.workspace = trueand other inherited fields, but the CLI is neither a member nor excluded, so Cargo refuses to build it as a workspace package. entropy's CLI is set up the same way. Only ore-stake lists its CLI. - Fix: add the CLI crates to
members, or give them their own manifest settings.
Review: Confirmed: ore/cli/Cargo.toml and entropy/cli/Cargo.toml use version.workspace = true etc., while the root members = ["api", "program"] (Cargo.toml:3) and no exclude; Cargo refuses a path package that sits under a workspace root without being a member. ore-stake lists its CLI (members includes "cli"). Build ergonomics of an operator tool, not shipped. LOW.
Excluded on review (2)
Findings the review showed to be wrong or a repeat of another finding. They are not counted above.
- 23. Agent-directed instructions in the ore deployment runbook (wrong): #23 (FALSE-POSITIVE as HIGH, reviewed INFO) and #24 (DUPLICATE of 23, reviewed INFO). I read
ore/docs/DEPLOY.mdin full and diffed it againstentropy/DEPLOY.md: the two files are identical exceptPROGRAM_IDandLIBRARY_NAME(orevsentropy_program). - 24. Agent-directed instructions in the entropy deployment runbook (duplicate): #23 (FALSE-POSITIVE as HIGH, reviewed INFO) and #24 (DUPLICATE of 23, reviewed INFO). I read
ore/docs/DEPLOY.mdin full and diffed it againstentropy/DEPLOY.md: the two files are identical exceptPROGRAM_IDandLIBRARY_NAME(orevsentropy_program).