Book a call Start a run

Crypto Security Bench

Data generated · 31 fixtures · 289 tasks

Crypto Security Bench is a benchmark that measures AI security agents’ ability to identify vulnerabilities in blockchain code. It includes 289 ground-truth vulnerabilities across 31 projects, spanning Solidity, Rust, C++, and Go codebases. Unlike other benchmarks like EVMBench, Crypto Security Bench includes both smart contracts as well as L1/core blockchain code. We compare our system, V12, with Claude Code, Codex, and Pashov Skills.

Overall performance

We ran each system against the codebases in Crypto Security Bench. We report recall, the fraction of ground truth bugs each system successfully identified.

Recall on all 289 tasks
System Detected Recall
V12 181 / 289 62.6%
Claude Code harness (Opus 4.7) 104 / 289 36.0%
Codex harness (GPT-5.5) 81 / 289 28.0%

Solidity head-to-head

Pashov Skills only audits Solidity, so it can’t be ranked against the others on the full 289-task set. We evaluated Pashov Skills on the 91 Solidity tasks:

Recall on the 91 Solidity tasks
System Detected Recall
V12 67 / 91 73.6%
Claude Code 52 / 91 57.1%
Codex 42 / 91 46.2%
Pashov Skills 40 / 91 44.0%

Performance by language

Recall grouped by language.

Recall by language
Language V12Claude Code harness (Opus 4.7)Codex harness (GPT-5.5)Pashov Skills
Rust 58.6% (85 of 145) 24.8% (36 of 145) 17.9% (26 of 145) Not evaluated
C++ 44.8% (13 of 29) 20.7% (6 of 29) 13.8% (4 of 29) Not evaluated
Solidity 73.6% (67 of 91) 57.1% (52 of 91) 46.2% (42 of 91) 44.0% (40 of 91)
Go 66.7% (16 of 24) 41.7% (10 of 24) 37.5% (9 of 24) Not evaluated
Recall per language: the share of that language's tasks each system detected. Rust 145 · C++ 29 · Solidity 91 · Go 24 tasks.

Detailed results

List of all ground-truth vulnerabilities included in Crypto Security Bench, along with the vulnerability reports generated by each system tested. Each fixture links to an associated human audit report.

Show fixtures in
  • Ante Solidity Onchain prediction-market protocol. Tasks 2 V12 2/2 Claude 1/2 Codex 1/2 Pashov 1/2
    Read the audit report
    • Ability to force tests to fail with gas limit ante-1 · 1 writeup critical

      V12

      Low-Gas Checks Falsely Fail

      contracts/AntePool.sol:292 sev: critical

      Root cause

      The pool collapses all checkTestPasses() reverts into a failed-test result. It does not enforce a minimum gas reserve or separate gas exhaustion from a deliberate false return by the Ante Test.

      Impact

      A challenger can force a passing pool into the failed state and redirect staker funds to challengers. This bypasses the invariant result entirely and can drain the staker side of a pool whose test requires more gas than the attacker forwards.

      Description

      checkTest() lets any eligible challenger choose the transaction gas for the verification call and then treats a failed external anteTest.checkTestPasses() call as a failed invariant. _checkTestNoRevert() catches every revert from that external call and returns false, without distinguishing an actual test failure from an out-of-gas failure induced by forwarding too little gas to the callee. After a false return, checkTest() records the pool as failed, sets the attacker as verifier, reserves the verifier bounty, and snapshots the staker balance for payouts. The attacker can then use claim() to receive challenger payout funds even though the Ante Test would have passed with sufficient gas.

    • Number of challengers is constrained by block gas limit ante-2 · 4 writeups high

      V12

      Unbounded Challenger Enumeration

      contracts/AntePool.sol:180 sev: high

      Root cause

      The contract stores challengers in an unbounded enumerable set and performs whole-set eligibility computation inside checkTest(). The minimum challenger stake is enforced only on entry, while partial challenger unstake allows low-cost persistent set growth.

      Impact

      A real failing test can be prevented from ever entering the failed state through the public verification path. Stakers can continue to use pre-failure flows while challengers cannot claim the staker-side payout promised by the pool after failure.

      Description

      checkTest() finalizes a failed test by calling _calculateChallengerEligibility(), which iterates over every address in the challengers set in a single transaction. Any external account can enter that set by staking the minimum challenger amount, and _unstake() removes the address only when the caller unstakes exactly its full stored balance. A challenger can therefore stake, partially unstake almost all capital, and leave a dust balance that keeps the address in challengers while recovering nearly all of the minimum deposit. Repeating this across many addresses makes failure finalization exceed the block gas limit, so the transaction that should set pendingFailure, compute the bounty, and enable claims reverts before the failure state is persisted.

      Claude Code harness (Opus 4.7)

      O(N) _calculateChallengerEligibility enables gas DoS of checkTest

      contracts/AntePool.sol:292 sev: high

      Root cause

      The failure path performs an unbounded O(N) loop over all challenger addresses, and becoming an entry only requires staking the minimum challenger stake from a fresh address.

      Impact

      A motivated attacker can Sybil thousands of challenger entries so that honest checkTest() attempts revert due to gas exhaustion. The pool becomes effectively unkillable, challengers cannot claim, and other paths depending on challenger iteration are bricked.

      Description

      _calculateChallengerEligibility iterates through the entire challengers.addresses array on every failure, allowing an attacker to add many cheap challenger addresses and make checkTest exceed the block gas limit.

      Codex harness (GPT-5.5)

      Dust challengers can make failed tests impossible to finalize

      contracts/AntePool.sol:187 sev: high

      Root cause

      MIN_CHALLENGER_STAKE is enforced only when ETH is added to the challenger side, while partial challenger unstakes are allowed with no minimum remaining balance and challengers remain in the iterable set unless the full stored balance is unstaked. Failure finalization then performs an unbounded iteration over all challenger entries.

      Impact

      This is a protocol-level liveness failure that can prevent settlement of a legitimate test failure and can let stakers escape a failed pool. Challengers can be denied the failed-test payout and the core Schelling mechanism breaks. The capital cost is close to zero beyond gas because the minimum challenge can be withdrawn immediately. The same bypass can also allow a 1 wei challenger to capture the 5% verifier bounty after waiting 12 blocks.

      Description

      AntePool attempts to limit challenger spam with MIN_CHALLENGER_STAKE, but the limit is enforced only when ETH is added to the challenger side. A challenger can stake the 0.01 ETH minimum, immediately unstake all but 1 wei, and remain in the challengers iterable set because _unstake() removes a challenger only when the full stored balance is unstaked. This makes it cheap to create an unbounded number of challenger entries.

      When a test fails, checkTest() calls _calculateChallengerEligibility(), which iterates over every address in challengers.addresses. Once enough dust challengers have been inserted, the failure transaction exceeds the block gas limit and reverts before pendingFailure, failedBlock, verifier, _bounty, and _remainingStake are finalized. The protocol can no longer record the test failure. During this period stakers can still use the normal unstake flow because pendingFailure remains false, so challengers can be denied the failed-test payout and the core Schelling mechanism breaks.

      The same minimum-balance bypass also lets a 1 wei challenger satisfy checkTest() and capture the 5% verifier bounty after waiting 12 blocks, because checkTest() only checks membership in the challenger set and the last stake block, not the current challenger balance.

      Pashov Skills

      AntePool sev: high

      Description

      _calculateChallengerEligibility iterates over the full challengers.addresses array in storage, and challenger registration has no effective floor (the MIN_CHALLENGER_STAKE check can be bypassed). An attacker can register a large number of challenger entries so the per-challenger loop exceeds the block gas limit; once that happens checkTest can no longer execute when a test fails, freezing payouts and locked funds until the entries are removed.

  • Anza Token Wrap Rust SPL token wrap program. Tasks 1 V12 0/1 Claude 1/1 Codex 0/1 Pashov —
    Read the audit report
    • Escrow accounts are not bound to caller, resulting in denial of service anza-token-wrap-1 · 1 writeup medium

      Claude Code harness (Opus 4.7)

      process_unwrap does not validate escrow / token-account / program ownership

      program/src/processor.rs:247 sev: high

      Root cause

      process_unwrap performs only two PDA checks and does not validate unwrapped_escrow.owner == unwrapped_token_program.key, unwrapped_mint.owner == unwrapped_token_program.key, wrapped_token_account.owner == wrapped_token_program.key, or that either token program is real. There is no analogue of the escrow base.owner == expected_authority check that exists in process_wrap.

      Impact

      In combination with findings #1/#2, the missing checks can enable abuse paths involving fake token programs and uncanonical escrows. Any token account owned by the wrapped mint authority and whose mint matches unwrapped_mint can be drained through unwrap by any holder of wrapped tokens, even if it is not the intended escrow. A user-supplied fake unwrapped token program can also cause self-grief by burning real wrapped tokens without transferring underlying tokens.

      Description

      process_unwrap validates only the wrapped-mint PDA and wrapped-mint-authority PDA. It does not verify ownership relationships among the escrow, mints, token accounts, and token programs, nor that the token programs are real supported token programs. It also does not validate that the supplied escrow is canonical or approved.

  • Aori Solidity Cross-chain RFQ settlement engine. Tasks 2 V12 0/2 Claude 1/2 Codex 0/2 Pashov 2/2
    Read the audit report
    • Missing reentrancy guard in the cancel function aori-1 · 2 writeups high

      Claude Code harness (Opus 4.7)

      cancel(bytes32) lacks nonReentrant

      Aori.sol:765 sev: low

      Root cause

      Missing nonReentrant modifier on cancel(bytes32) while _cancel performs an external transfer.

      Impact

      For a native-token order or ERC-777 input token, the recipient can re-enter Aori. No fund-loss path was found because status and locked balance are updated before transfer, but the missing guard removes a safety belt and deviates from the contract pattern.

      Description

      cancel(bytes32) lacks the nonReentrant guard used by other state-changing entry points, despite ultimately performing an external token transfer to order.offerer.

      Pashov Skills

      Aori sev: medium

      Description

      cancel(bytes32) is the only fund-transferring entry point that lacks the nonReentrant modifier. Aori.sol:765 declares it as external whenNotPaused, while the other state-mutating user-facing functions are protected by nonReentrant. Because _cancel transfers order.inputToken back to order.offerer, a native-token or callback-token recipient can introduce a reentrancy surface around cancellation. Although the order status and locked balance are updated before this transfer, the inconsistent guard leaves the cancel path exposed to callback-driven reentry risk.

    • Incorrect state update on settlement failure aori-2 · 1 writeup high

      Pashov Skills

      Aori sev: medium

      Description

      _settleOrder decreases offerer's locked before validating filler-side overflow, leaving an orphan debit on failure. Aori.sol:663-672 calls decreaseLockedNoRevert (which mutates balance.locked and returns true) and only THEN calls increaseUnlockedNoRevert; on a filler-side overflow (successUnlock == false) the function returns at line 670 without rolling back the offerer's locked decrement, and the orderStatus = Settled write at line 673 is skipped. The orderId has already been popped from srcEidToFillerFills by packSettlement, so settlement cannot be retried and the LZ message is consumed; for cross-chain orders the offerer has no recovery path (source cancel is forbidden by srcEid == dstEid, dst-cancel requires Unknown) except owner emergencyCancel. The trigger requires the filler's unlocked balance to be near uint128.max, which is implausible for normal ERC-20 decimals but reachable for low-decimal/wrapped tokens or via deliberate accumulation.

  • Audius Solana Rust Audius reward distribution on Solana. Tasks 1 V12 1/1 Claude 0/1 Codex 1/1 Pashov —
    Read the audit report
    • Missing PDA validation leading to multiple transfers audius-solana-1 · 2 writeups critical

      V12

      Replay marker account not verified

      solana-programs/reward-manager/program/src/processor.rs:433 sev: critical

      Root cause

      process_evaluate_attestations derives the transfer-marker PDA but does not enforce that transfer_account_info equals that derived address before creating the replay marker.

      Impact

      The same valid attestation set can be replayed to pay the same reward multiple times until the program-controlled reward token source is depleted. This is direct unauthorized token loss from the reward manager whenever a valid transfer has been attested once.

      Description

      process_evaluate_attestations intends to prevent duplicate disbursement by rejecting an existing transfer marker and then creating a marker derived from the reward-manager and transfer id. The function only checks that the caller-supplied transfer_account_info has zero lamports before the token transfer, but it never compares transfer_account_info.key to the derived transfer PDA. After spl_token_transfer succeeds, the function derives the expected transfer seed but discards the derived address and calls create_account on the same unchecked caller-supplied account. An attacker who can supply any fresh signer-owned system account as transfer_account_info can make the marker creation succeed at that arbitrary account while the canonical marker for the id remains absent. The same quorum of stored attestations can then be evaluated again with another fresh transfer account, bypassing the replay protection for the same reward id.

      Codex harness (GPT-5.5)

      Transfer replay marker PDA is not enforced, allowing repeated payouts for the same signed reward

      sev: critical

      Root cause

      The on-chain processor derives but does not enforce that transfer_account_info.key equals the expected transfer replay marker PDA, allowing arbitrary signer accounts to satisfy account creation.

      Impact

      This breaks the core "each reward can be sent only once" invariant and allows anyone with public historical attestations to replay a payout until the reward token account is drained.

      Description

      process_evaluate_attestations checks only whether the caller-supplied transfer_account_info currently has lamports. The function later derives the expected transfer PDA from TRANSFER_SEED_PREFIX || id, but discards the derived address. Because transfer_account_info.key is never compared to the derived PDA, a caller can pass any fresh system account as the transfer account and mark it as a signer in a manually constructed instruction. The CPI to system_instruction::create_account can then succeed using the account's normal transaction signature instead of the intended PDA signature. The canonical T_<id> PDA remains uncreated, so the replay check never records that the id was consumed.

  • Bracket FI Escrow Solidity OTC escrow with seller-driven deposits. Tasks 4 V12 3/4 Claude 2/4 Codex 2/4 Pashov 3/4
    Read the audit report
    • Reentrancy in withdrawals bracket-fi-escrow-1 · 4 writeups critical

      V12

      Pre-Update Withdrawal Reentrancy

      src/EscrowBase.sol:116 sev: critical

      Root cause

      withdraw violates checks-effects-interactions by making external WETH and msg.sender calls before decrementing usersBalance and totalStaked. The function also lacks a reentrancy guard for the ETH unwrap path.

      Impact

      An attacker with a small WETH-denominated escrow balance can drain native ETH/WETH backing that belongs to other depositors before the escrow break. The attack directly transfers escrow assets to the attacker and leaves remaining users undercollateralized.

      Description

      withdraw performs the value-moving interactions for an unwrapped withdrawal before it reduces the caller’s escrow balance. For ETH-backed tokens, the function calls WETH-style withdraw(uint256) and then forwards native ETH to msg.sender while usersBalance[msg.sender][token] still contains the pre-withdrawal balance. A malicious contract can use its payable callback to reenter withdraw(token, amount, true) and pass the same balance check again. Because each nested call consumes escrow-held WETH/ETH before the outer call writes accounting, the attacker receives more native ETH than their recorded stake while the final user-balance write only reflects one withdrawal amount.

      Claude Code harness (Opus 4.7)

      Reentrancy in withdraw enables full drain of escrow holdings

      src/EscrowBase.sol:136 sev: critical

      Root cause

      State variables tracking the user's right to funds (usersBalance and totalStaked) are mutated only after sending ETH or tokens to msg.sender, and there is no nonReentrant guard.

      Impact

      An attacker can repeatedly reenter withdraw from the ETH receive path before their balance is decremented, draining the escrow's WETH/ETH holdings with a near-zero deposit. Similar risk exists for callback-capable LSTs.

      Description

      withdraw performs external value transfers before updating the user's balance and total staked accounting, with no reentrancy guard.

      Codex harness (GPT-5.5)

      ETH unwrap withdrawals are reentrant and can drain other users' WETH

      src/EscrowBase.sol:136 sev: high

      Root cause

      EscrowBase.withdraw performs external interactions, including WETH unwrap and ETH transfer with a full-gas call, before updating usersBalance and totalStaked.

      Impact

      If the escrow also holds other users' WETH, the attacker receives both ETH from the outer withdrawal and WETH from the reentrant withdrawal, leaving the escrow insolvent for honest depositors. The impact is direct theft of escrowed assets and broken accounting.

      Description

      EscrowBase.withdraw performs external interactions before it updates the caller's escrow balance and totalStaked. In the ETH unwrap branch, the contract unwraps WETH and then sends ETH to msg.sender with a full-gas call while the caller's WETH balance is still unchanged. An attacker can deposit WETH/ETH, call withdraw(wETH, amount, true) from a contract, and reenter from the contract's ETH receive hook before the first withdrawal decrements accounting. During reentry, s.usersBalance[attacker][wETH] still includes the full amount, so a second withdraw(wETH, amount, false) passes the balance check and transfers WETH out of the escrow. The outer call then decrements the attacker's balance only once. This attack requires the escrow to hold at least amount of WETH liquidity from other users when the reentrant withdrawal executes.

      Pashov Skills

      EscrowBase sev: medium

      Description

      The non-ETH withdrawal branches also perform external token transfers before the withdrawal accounting is fully finalized. The safeTransfer(rebase, finalAmt) and safeTransfer(token, amount) paths can invoke token receiver callbacks when a whitelisted ERC777-style or callback-capable token is used. In that case, the same checks-effects-interactions violation as the ETH withdrawal path can be reentered without relying on an ETH transfer.

    • Centralization Risk bracket-fi-escrow-2 · 1 writeup high

      Pashov Skills

      EscrowBase sev: medium

      Description

      The escrow owner holds excessive unilateral power: withdrawEscrow lets the owner drain all escrowed tokens, and the bridge functions let the owner transfer tokens to any L2 address. With no distribution constraint, a single owner key — or a compromised one — can move user assets directly out of the contract.

    • Nonpayable bridgeTokenArb function bracket-fi-escrow-3 · 4 writeups medium

      V12

      Arbitrum Fees Not Forwarded

      src/BridgeEscrow.sol:47 sev: high

      Root cause

      The Arbitrum bridge wrapper omits both a payable entrypoint and value forwarding for a payable cross-chain router operation that depends on native fee funding.

      Impact

      Escrowed tokens can become unable to move through the Arbitrum bridge path after the break because every bridge attempt reaches the router with zero fee value. Users cannot withdraw directly at that point, so assets remain stuck until the contract is upgraded or another recovery path is introduced.

      Description

      bridgeTokenArb() is the post-break Arbitrum bridge wrapper, but it is not payable and it calls bridgeRouter.outboundTransferCustomRefund(...) without a {value: ...} clause. Solidity sends zero native value on that router call regardless of any ETH previously held by the escrow. Arbitrum L1 token deposits use the payable router call’s msg.value to fund retryable-ticket submission and L2 gas costs, so normal nonzero maxGas and gasPrice bridge attempts cannot be funded through this wrapper. Once the escrow is broken, this is the owner’s Arbitrum exit path while inherited user withdrawals are no longer available.

      Claude Code harness (Opus 4.7)

      bridgeTokenArb not payable; Arbitrum L1 → L2 bridging cannot supply L2 gas

      src/BridgeEscrow.sol:47 sev: high

      Root cause

      The wrapper calls payable IL1GatewayRouter.outboundTransferCustomRefund without accepting or forwarding msg.value.

      Impact

      Migration to L2 may revert for insufficient submission cost or create a failing retryable ticket, potentially leaving tokens stuck in the L1 gateway pending manual rescue.

      Description

      Arbitrum bridging requires ETH for retryable ticket costs, but bridgeTokenArb is not payable and forwards no value.

      Codex harness (GPT-5.5)

      Arbitrum bridge path is unusable because retryable-ticket data and ETH value are never supplied

      src/BridgeEscrow.sol:47 sev: high

      Root cause

      bridgeTokenArb is non-payable, calls the Arbitrum gateway router with empty _data, and does not forward ETH required to fund retryable-ticket submission and execution.

      Impact

      After escrow break, users can no longer withdraw directly because withdraw is gated by onlyNotBroke. The owner calls bridgeTokenArb to move WETH/wstETH/rETH/etc. to the Arbitrum escrow, but the transaction reverts due to malformed bridge data or insufficient retryable-ticket funding. Funds remain stranded in the L1 escrow until an upgrade or out-of-band migration is performed.

      Description

      BridgeEscrow.bridgeTokenArb is intended to move escrowed L1 tokens to the Arbitrum escrow after the break timestamp, but it calls the Arbitrum gateway router with empty _data and forwards no ETH. The Arbitrum L1 gateway expects router-supplied user data to contain at least abi.encode(maxSubmissionCost, callHookData), and it forwards msg.value to fund retryable-ticket submission and execution. In the vendored Arbitrum gateway implementation, L1ArbitrumGateway.outboundTransferCustomRefund parses the router data and calls _parseUserEncodedData; _parseUserEncodedData decodes the payload as (uint256, bytes). Passing bytes("") therefore reverts during decoding before any bridge deposit is initiated. Even if calldata were encoded, the escrow function is not payable and does not forward ETH for the retryable ticket.

      Pashov Skills

      BridgeEscrow sev: medium

      Description

      bridgeTokenArb is non-payable and does not forward ETH to the Arbitrum L1 gateway router. The function lacks the payable modifier, calls outboundTransferCustomRefund without a {value: ...} payment, and hard-codes the router data argument to bytes(""). Standard Arbitrum L1 gateway routers require ETH and retryable-ticket parameters for the outbound transfer, so this bridge path can revert and become non-functional.

    • Allowance given to incorrect address bracket-fi-escrow-4 · 1 writeup medium

      V12

      Gateway pull lacks allowance

      src/BridgeEscrow.sol:47 sev: high

      Root cause

      bridgeTokenArb() approves the router address instead of approving the token-specific Arbitrum L1 gateway that executes the ERC20 transferFrom.

      Impact

      The intended Arbitrum exit can fail for the escrowed ERC20s even when fee funding is otherwise fixed. Because the function is only usable after the escrow break and user withdrawals are disabled then, this leaves the affected L1 assets stuck until governance performs an upgrade or manual recovery.

      Description

      bridgeTokenArb() increases ERC20 allowance for address(bridgeRouter) and then asks the Arbitrum router to perform outboundTransferCustomRefund. In Arbitrum's routed token-bridge architecture, the router dispatches the deposit to the token-specific L1 gateway, and that gateway is the spender that pulls tokens from the L1 sender. ERC20 allowances are keyed by (owner, spender), so approving the router does not authorize a distinct gateway to transfer tokens out of BridgeEscrow. For tokens whose resolved gateway is not the router itself, the post-break bridge call reaches the gateway without allowance and reverts at the token pull step.

  • Celestia PFM Go Cosmos IBC packet-forward middleware. Tasks 2 V12 1/2 Claude 2/2 Codex 0/2 Pashov —
    Read the audit report
    • There is no upper limit to the time-out on PFM packets celestia-pfm-1 · 2 writeups medium

      V12

      Unbounded Forward Timeout Pins State

      middleware/packet-forward-middleware/packetforward/ibc_middleware.go:182 sev: medium

      Root cause

      OnRecvPacket validates only that the forward timeout is positive before using it. It lacks a maximum timeout cap for the delayed-ack forwarding state created by ForwardTransferPacket.

      Impact

      An attacker can create long-lived packet-forward state and delayed acknowledgements with cheap transfers. The attack pins store entries and IBC commitments until the far-future timeout or a downstream acknowledgement occurs, imposing persistent state growth and liveness pressure on chains that process many such packets.

      Description

      OnRecvPacket accepts the attacker-controlled forward.timeout value from packet memo metadata and only replaces it with the middleware default when the value is non-positive. There is no upper bound before the timeout is passed into ForwardTransferPacket. ForwardTransferPacket converts that duration directly into the outgoing packet timeout timestamp and then stores an in-flight record keyed by the outgoing sequence. Because forwarded acknowledgements are intentionally delayed until the downstream packet acknowledges or times out, a sender can choose a very large positive duration and keep the in-flight record and previous-hop acknowledgement pending for years. The Duration JSON parser permits numeric nanosecond values or duration strings, so this control is exposed through the untrusted memo.

      Claude Code harness (Opus 4.7)

      ForwardMetadata.Validate() does not bound Timeout; user can extend forward lifetime far beyond operator default

      middleware/packet-forward-middleware/packetforward/types/forward.go:32 sev: medium

      Root cause

      Validate() does not check Timeout. The user can supply an arbitrary positive nanoseconds value and the middleware uses that value verbatim for the outbound packet's TimeoutTimestamp.

      Impact

      A user can keep funds tied up in escrow for arbitrarily long periods, hold InFlightPacket entries for years, and extend the lifetime of leaked entries from Finding 1. The unbounded timeout is persisted into InFlightPacket.Timeout and reused on retries.

      Description

      Forward timeout supplied in metadata is accepted without a maximum and used verbatim when positive.

    • Missing prefix for RefundPacketKey celestia-pfm-2 · 1 writeup medium

      Claude Code harness (Opus 4.7)

      RefundPacketKey namespace shares the entire keeper store with no prefix, mixing with future stored data and with InitGenesis-written keys

      middleware/packet-forward-middleware/packetforward/types/keys.go:24

      Root cause

      RefundPacketKey returns raw channel/port/sequence bytes with no prefix. ExportGenesis uses store.Iterator(nil, nil) and MustUnmarshal on every entry as an InFlightPacket.

      Impact

      Future keeper storage can collide with in-flight entries and break export/import. If a non-InFlightPacket key such as ParamsKey is ever written, ExportGenesis can panic during chain export or upgrades.

      Description

      In-flight packet keys have no store prefix and ExportGenesis iterates the entire keeper store as if every key were an InFlightPacket.

  • Chainlocker Solidity Self-custody escrow for buyer/seller deals. Tasks 6 V12 6/6 Claude 3/6 Codex 3/6 Pashov 0/6
    Read the audit report
    • Potential funds loss for buyers upon approval chainlocker-1 · 2 writeups critical

      V12

      Allowance Deposits Lack Depositor Authorization

      src/TokenLocker.sol:410 sev: high

      Root cause

      The allowance-based deposit path treats ERC20 allowance to the locker as permission for any caller to initiate a deposit on behalf of _depositor.

      Impact

      Anyone can spend a victim’s approval to move the victim’s tokens into the escrow. For non-refundable or seller-approved escrows, this can convert a stray or pre-granted allowance into loss of the victim’s deposit or full payment to the seller without an action by the victim.

      Description

      depositTokens() accepts an arbitrary _depositor and never requires _depositor == msg.sender or any authorization from the depositor for this specific deposit. If an address has approved the locker, any external caller can pass that address as _depositor; the function only checks the allowance and then pulls tokens with safeTransferFrom(). In open-offer lockers this can force a victim with a sufficient allowance to become buyer, set deposited, and expose the victim’s funds to execution or non-refundable expiry controlled by the seller’s approval and oracle conditions. In fixed-buyer lockers, any third party can still spend the buyer’s allowance into the escrow before the buyer intends to fund it, removing the buyer’s control over timing. The permit path obtains a signature, but the allowance path relies solely on standing ERC20 approval and treats any caller as authorized to use it.

      Claude Code harness (Opus 4.7)

      Anyone can deposit ETH to a !openOffer EthLocker via receive()

      src/EthLocker.sol:264 sev: low

      Root cause

      receive() does not check msg.sender == buyer when !openOffer, while rejectDepositor is unavailable and expiry refunds only buyer.

      Impact

      Self-inflicted permanent loss for confused third parties funding the wrong locker; UX hazard.

      Description

      In non-open-offer EthLocker, any address can send ETH and have it recorded, but there is no refund path for arbitrary third-party depositors.

    • updateBuyer does not update amountDeposited mapping chainlocker-2 · 2 writeups critical

      V12

      Buyer updates orphan deposits

      src/TokenLocker.sol:429 sev: high

      Root cause

      updateBuyer() updates role state without updating the per-depositor accounting that rejection and later open-offer acceptance rely on.

      Impact

      A buyer who rotates their buyer address can have their escrowed tokens left behind and later paid to the seller instead of being refunded on rejection. The exploit lets a malicious seller turn a normal buyer-address update into loss of the buyer's deposited funds.

      Description

      updateBuyer() changes the global buyer address but does not move the existing amountDeposited balance from the old buyer to the new buyer. In an open-offer escrow, the seller can then call rejectDepositor() for the current buyer address; because the deposit remains recorded under the old address, the function deletes buyer and deposited but refunds nothing. The original tokens remain in the contract while the offer is reopened, and the next depositor can become buyer based on the pre-existing balance because deposit acceptance uses erc20.balanceOf(address(this)) + _amount. A colluding new buyer can then approve execution and have execute() transfer the orphaned tokens to the seller.

      Claude Code harness (Opus 4.7)

      updateBuyer / updateSeller do not migrate amountDeposited, leaving stale entries vulnerable to rejectDepositor

      src/EthLocker.sol:297 sev: medium

      Root cause

      updateBuyer rewrites buyer to a new address but amountDeposited[oldBuyer] retains the deposited balance.

      Impact

      Seller can reject the old buyer in open-offer mode and pay old buyer from funds owed to the new buyer; stale entries can also remain exploitable after execute().

      Description

      Changing buyer does not move the old buyer's recorded deposit to the new buyer, leaving stale amountDeposited entries exploitable through rejection or later cycles.

    • Buyers can prevent themselves from being rejected chainlocker-3 · 1 writeup high

      V12

      Push refunds can be blocked

      src/TokenLocker.sol:59 sev: high

      Root cause

      The contract implements rejection and expiry settlement as mandatory synchronous push transfers to untrusted recipients, with no pull-payment accounting or alternate recovery route when a token transfer to one recipient fails.

      Impact

      A buyer can prevent the seller from rejecting their open-offer deposit when the configured token can make transfers to that buyer fail. At expiry, a failing transfer to either settlement recipient blocks the entire payout path and freezes the locker balance until that external recipient condition changes.

      Description

      All TokenLocker settlement and rejection payouts are push transfers that must succeed inside the same transaction. safeTransfer() reverts the whole caller whenever the token transfer call reverts or returns failure, and rejectDepositor() relies on that helper to refund an open-offer depositor before the seller can clear them. checkIfExpired() similarly pushes the non-refundable deposit to seller and the remainder or refundable balance to buyer in one atomic path. For ERC20-compatible tokens with recipient-controlled hooks, or blacklistable and pausable token implementations, a malicious or blocked recipient can make one transfer fail and thereby prevent rejection or expiry settlement for everyone.

    • Irrevertible loss of tokens chainlocker-4 · 3 writeups high

      V12

      Rejection Burns Residual Open-Offer Deposits

      src/TokenLocker.sol:429 sev: critical

      Root cause

      The buyer rejection path clears the single settlement recipient while leaving other open-offer depositor balances in the contract, and expiry settlement ignores those per-depositor records.

      Impact

      Open-offer contributors who were not the rejected buyer can permanently lose their remaining deposited tokens. The funds are sent to the zero address on expiry rather than being returned according to the amountDeposited ledger.

      Description

      rejectDepositor() lets the seller reject the current open-offer buyer by deleting both deposited and buyer, then refunds only amountDeposited[_depositor] for the rejected address. Earlier partial depositors can still have positive amountDeposited entries and tokens in the contract, because open-offer deposits are tracked per address while buyer acceptance is based on aggregate balance. After the buyer is rejected, the remaining balance stays in the locker with buyer == address(0). When the locker expires, checkIfExpired() treats _isDeposited as false and, in the refundable branch or the non-deposited fallback, transfers the entire remaining balance to buyer, which is the zero address. The seller can therefore reject the threshold-crossing buyer while leaving other contributors’ deposits to be burned on expiry instead of refunded to the recorded depositors.

      Claude Code harness (Opus 4.7)

      After rejectDepositor(buyer) in an open-offer locker, residual deposits from other addresses are sent to address(0) at expiry (burnt)

      src/EthLocker.sol:386 sev: high

      Root cause

      rejectDepositor() deletes buyer when _depositor == buyer but does not refund or clear other depositors. checkIfExpired() later pays the remaining balance/remainder to buyer, which may be address(0).

      Impact

      Loss of funds for non-buyer depositors who contributed partial amounts; for TokenLocker, permanent freeze for most ERC20s.

      Description

      Rejecting the current buyer clears buyer to address(0) while residual deposits from other addresses can remain in the contract. At expiry, remaining funds are sent to buyer, causing ETH burns or token transfer reverts/freezes.

      Codex harness (GPT-5.5)

      Open-offer deposits are accounted globally, allowing one depositor to take other users' funds at expiry

      sev: high

      Root cause

      In EthLocker.receive(), deposit acceptance is based on address(this).balance >= deposit, not amountDeposited[msg.sender] >= deposit. TokenLocker has the same pattern: token deposit functions compute aggregate _balance, set buyer when aggregate _balance >= deposit, and then record _amount for that depositor. On expiry, both lockers ignore per-depositor accounting and pay the escrow balance to buyer.

      Impact

      This can directly steal funds from earlier partial depositors and can also corrupt non-refundable expiry accounting. If the seller rejects a non-buyer contributor after the aggregate threshold has been crossed, deposited remains true while the actual balance may fall below deposit; the non-refundable expiry path then underflows at balance - deposit and reverts, blocking expiry processing.

      Description

      In open offers, the address that happens to push the aggregate escrow balance over deposit becomes buyer, even if that address supplied only a small fraction of the required deposit. At expiry, the contracts then transfer the entire refundable balance, or the entire amount above the non-refundable deposit, to that single buyer. This can directly steal funds from earlier partial depositors and can also corrupt non-refundable expiry accounting.

      Exploit scenario for a refundable open ETH offer with deposit = 100 ETH and totalAmount = 200 ETH:

      1. Alice deposits 99 ETH. Because the total balance is below deposit, no buyer is assigned.

      2. Bob deposits 1 ETH. The aggregate balance is now 100 ETH, so Bob becomes buyer even though he contributed only 1 ETH.

      3. The locker expires without execution.

      4. checkIfExpired() transfers the whole 100 ETH balance to Bob, stealing Alice's 99 ETH.

      The non-refundable path is also unsafe. If additional partial contributors deposit after Bob becomes buyer, Bob receives all escrowed value above deposit at expiry even though those funds may belong to others.

    • buyerApproved not cleared when buyer is rejected chainlocker-5 · 2 writeups medium

      V12

      Stale Approval Bypasses Buyer Consent

      src/TokenLocker.sol:429 sev: critical

      Root cause

      buyerApproved is not invalidated when rejectDepositor() removes the approved buyer or when a new open-offer buyer is assigned. Approval state is stored as an address-independent boolean and later trusted by execute().

      Impact

      A malicious seller can take a later open-offer depositor’s escrowed tokens without that depositor calling readyToExecute(). The victim loses the full token amount once the seller approves and execute() transfers totalAmount to the seller.

      Description

      In open-offer escrows, buyerApproved is a global boolean rather than approval bound to the current buyer. A seller can accept an accomplice as the first buyer, have that accomplice call readyToExecute(), and then call rejectDepositor() to delete buyer and deposited while leaving buyerApproved set. When a later victim deposits enough tokens to become the new buyer, depositTokens() assigns buyer to the victim but does not clear the stale approval. execute() only checks the stale boolean and the seller’s approval, so the seller can release the victim’s full totalAmount to themselves without the victim ever approving execution.

      Codex harness (GPT-5.5)

      Stale approvals can be reused after buyer replacement or rejection to execute without the current buyer's consent

      sev: high

      Root cause

      readyToExecute() stores approvals as role-level booleans, but updateBuyer(), updateSeller(), and rejectDepositor() do not clear those booleans when the address assigned to a role changes. Execution only checks the booleans, not which address set them.

      Impact

      A new buyer can inherit a previous buyer's approval, allowing a seller to execute an open offer against the new buyer's funds without that buyer ever calling readyToExecute(). This violates the documented execution condition and can directly release a new buyer's escrowed assets without their execution consent.

      Description

      readyToExecute() stores approvals as role-level booleans, but updateBuyer(), updateSeller(), and rejectDepositor() do not clear those booleans when the address assigned to a role changes. A new buyer can therefore inherit a previous buyer's approval, allowing a seller to execute an open offer against the new buyer's funds without that buyer ever calling readyToExecute().

      Exploit scenario:

      1. Buyer1 accepts an open offer and calls readyToExecute(), setting buyerApproved = true.

      2. The seller calls rejectDepositor(Buyer1). Buyer1's funds are returned and buyer/deposited are cleared, but buyerApproved remains true.

      3. Buyer2 accepts the reopened offer and deposits the required assets. Buyer2 has not called readyToExecute().

      4. The seller calls readyToExecute().

      5. Anyone can call execute(). The contract sees both booleans as true and releases Buyer2's escrowed funds to the seller using Buyer1's stale approval.

      A similar stale-consent path exists when a buyer calls readyToExecute() and then changes buyer to a replacement address with updateBuyer(); the replacement buyer inherits the old approval. sellerApproved is likewise not cleared after updateSeller(), so the approval state can stop matching the current role addresses on either side.

      This violates the documented execution condition that the current buyer and seller have both called readyToExecute() and can directly release a new buyer's escrowed assets without their execution consent.

    • Griefing in checkIfExpired chainlocker-6 · 2 writeups medium

      V12

      Reverting Recipients Freeze Settlement

      src/EthLocker.sol:38 sev: critical

      Root cause

      The expiry flow uses all-or-nothing push transfers to role addresses and provides no pull-based withdrawal or post-expiry recovery path when a recipient rejects ETH.

      Impact

      A malicious buyer can freeze the seller’s non-refundable deposit, and a malicious seller can freeze the buyer’s remainder after expiry. Because role updates are gated behind the same failing expiry settlement, the frozen ETH has no in-contract recovery path after expiration.

      Description

      Expiry settlement is implemented as mandatory push ETH transfers to seller and buyer using safeTransferETH, which reverts if the recipient rejects ETH. updateSeller() and updateBuyer() both call checkIfExpired() before changing the role address, so once expiration is reached a reverting recipient cannot be replaced because the same failing settlement runs first. In a non-refundable escrow, either party can set its own role to a contract that reverts on receipt before expiry. When checkIfExpired() later tries to distribute the seller’s deposit and buyer’s remainder, the reverting recipient causes the whole transaction to revert and permanently blocks the counterparty’s payout.

      Codex harness (GPT-5.5)

      ETH expiry can permanently lock funds when the buyer or seller cannot receive ETH

      sev: high

      Root cause

      EthLocker uses push payments during expiry and does not provide a fallback withdrawal path. The address update functions call checkIfExpired() first, so once expiry has been reached, a failing transfer prevents changing the recipient to a payable address.

      Impact

      This freezes the escrowed ETH and, in the non-refundable case, can also prevent the seller from receiving the non-refundable deposit. The same issue can occur accidentally with multisigs or account-abstraction wallets that do not accept plain ETH transfers.

      Description

      EthLocker uses push payments during expiry and does not provide a fallback withdrawal path. If the current buyer or, for non-refundable deposits, seller is a contract that reverts on receiving ETH or lacks a payable receive/fallback function, every call to checkIfExpired() reverts and the escrow cannot be expired. Because updateBuyer() and updateSeller() call checkIfExpired() before updating the address, the parties also cannot repair the recipient address after expiry.

      Exploit scenario:

      1. A non-refundable open offer is accepted by a buyer contract whose receive() always reverts.

      2. The locker expires without execution.

      3. checkIfExpired() attempts to send deposit to seller and the remainder to buyer, or sends the full balance to buyer in the refundable case.

      4. The transfer to the reverting buyer reverts the whole transaction. isExpired and the deletion of deposited are reverted as well.

      5. updateBuyer() cannot be used after expiry because it also enters the same reverting checkIfExpired() path first.

  • Chateau Solidity Yield-bearing real-asset vault. Tasks 4 V12 4/4 Claude 4/4 Codex 2/4 Pashov 4/4
    Read the audit report
    • Potential DOS during swap chateau-1 · 4 writeups critical

      V12

      Unbounded stake loop disables swaps

      contracts/StakingPool.sol:49 sev: medium

      Root cause

      swap() performs unbounded iteration over globally append-only stake indexes, while stake() has no minimum economically meaningful stake size or pagination mechanism to cap the work imposed on future swaps.

      Impact

      An attacker can make the share-to-issue-token redemption path unavailable for all users by paying only the gas and minimal token amounts needed to create many stake records. The attack blocks ordinary swap() redemptions and leaves pending staked liquidity unusable through the intended swap mechanism.

      Description

      stake() accepts any positive amount and creates a new issues[indexEnd] entry for every deposit, then increments indexEnd. swap() must iterate from the current indexEnd down to indexStar on every call, touching every historical issue index and performing storage reads even for indexes that are empty or inactive. Because a valid non-American user can repeatedly stake the smallest supported unit, the attacker can grow indexEnd - indexStar until the loop in swap() exceeds the block gas limit. Once that threshold is reached, share holders cannot use swap() to redeem through this pool unless privileged intervention resets indexStar by withdrawing the pool liquidity.

      Claude Code harness (Opus 4.7)

      Unbounded loops in unstake(), swap(), getStakingInfo()

      contracts/StakingPool.sol:60 sev: medium

      Root cause

      userIssueIndex entries are pushed in stake() and never removed; swap() scans the entire global index range and does not break once amountB == 0; getStakingInfo() scans userIssueIndex[user] twice.

      Impact

      A user can be permanently denied exit by sufficiently many historical stake/unstake cycles. An attacker or heavy use can grief everyone via swap() if indexEnd - indexStar grows large enough that the loop becomes too expensive to fit in a block.

      Description

      Several functions iterate over arrays or index ranges that grow without bound, and processed entries are never removed.

      Codex harness (GPT-5.5)

      Anyone can permanently make swap() run out of gas by creating historical stake records

      contracts/StakingPool.sol:128 sev: high

      Root cause

      swap() iterates from the global indexEnd down to indexStar on every call, skips inactive records only inside the loop, does not break after amountB == 0, and unstake() marks positions inactive without removing their ids or advancing a global cursor.

      Impact

      An attacker can repeatedly stake and unstake a minimal amount to permanently increment indexEnd and leave inactive Issue entries behind. Later legitimate swaps must scan all those entries and can become unexecutable within the block gas limit, freezing the staking pool's liquidation workflow until the owner calls withdraw(), which drains active deposits and triggers the separate off-by-one issue.

      Description

      The core swap path performs unbounded iteration over every issue id since the last indexStar reset. A non-US account can create arbitrarily many historical records with negligible capital by repeatedly staking and unstaking tiny amounts, making future swaps exceed the block gas limit and freezing the staking pool's liquidation workflow.

      Pashov Skills

      StakingPool sev: critical

      Description

      swap() iterates the entire issues array (one entry per stake() deposit) from indexEnd down to indexStar, processing every staking issue. stake() enforces no minimum amount, so an attacker can create a very large number of tiny stake entries; the resulting array makes the swap loop perform enough iterations to exhaust the block gas limit, preventing swaps from completing.

    • Transparent pool ordering allows for preferential swaps chateau-2 · 3 writeups critical

      V12

      Newest stakes capture swaps

      contracts/StakingPool.sol:49 sev: medium

      Root cause

      swap() uses deterministic newest-first settlement over append-only stake indexes instead of a non-manipulable, pro-rata, FIFO, or otherwise fair allocation rule. Because stake() remains open until the same block, attackers can insert priority liquidity immediately before valuable swaps.

      Impact

      The attacker can extract favorable share-token allocations from pending swaps without bearing the same waiting time as existing stakers. Existing stakers are pushed behind the attacker's just-in-time liquidity and lose the intended opportunity to have their stake converted in that swap.

      Description

      stake() appends each deposit at the current indexEnd, making later deposits occupy higher issue indexes. swap() deterministically walks the issue set from indexEnd downward to indexStar, so the newest active stakes are always filled before older stakes. A non-American attacker who sees a pending profitable swap() can front-run it by staking just enough issueToken to sit at the highest active index. The victim's swap() then transfers the victim's redeemToekn to the attacker-controlled new stake before any older staker is considered, letting the attacker capture the share-token distribution that existing stakers were queued to receive.

      Claude Code harness (Opus 4.7)

      LIFO processing in swap() can permanently lock out older stakers

      contracts/StakingPool.sol:128 sev: high

      Root cause

      The loop walks from newest to oldest (LIFO) instead of FIFO, and matching stops once swap volume is exhausted.

      Impact

      A user who stakes early can never escape the queue if newer stakers keep arriving. Their Issue.isStaking can remain true forever and their only exit is unstake(), which depends on the owner not having called withdraw().

      Description

      swap() walks from indexEnd down to indexStar + 1, processing newest stakers first and stopping once amountB == 0, so older stakers only get processed after every newer staker is consumed.

      Pashov Skills

      StakingPool sev: medium

      Description

      swap matches stakes in last-in-first-out order. The loop for (i = indexEnd; i > indexStar; i--) processes the newest stake entries before older ones, allowing a participant to stake immediately before a swap and receive preferential matching ahead of earlier stakers. This creates a fairness and MEV risk if the protocol expects older staking positions to be served first.

    • Withdraw leads to loss of stake chateau-3 · 4 writeups critical

      V12

      Post-Withdrawal Stake Is Skipped

      contracts/StakingPool.sol:49 sev: high

      Root cause

      withdraw() stores the cutoff as indexStar = indexEnd even though indexEnd is the next unused issue id. Subsequent accounting uses strict index > indexStar checks, so the next stake is misclassified as pre-withdrawal state.

      Impact

      The first depositor after any admin withdrawal cannot recover that deposit through unstake() because their index is treated as already withdrawn. A caller with enough share tokens can drain that deposit through swap() without compensating the skipped staker with shares.

      Description

      withdraw() transfers the pool’s entire issueToken balance to the owner and then sets indexStar = indexEnd. Because indexEnd is the next stake index, the very next stake() writes its Issue at exactly the same index that withdraw() saved as the liquidation cutoff. All later readers require index > indexStar, so that first post-withdrawal stake is excluded from unstake(), getStakingInfo(), and the swap() distribution loop even though stake() increments pendingLiquidation for it. A share holder can then call swap() against the inflated pendingLiquidation; the loop never allocates shares to the skipped staker, but the function still decrements pendingLiquidation and transfers the staker’s issueToken to the swap caller. This creates a concrete theft path for the first user who stakes after an admin withdrawal.

      Claude Code harness (Opus 4.7)

      Stakes created *after* a withdraw() are immediately unrecoverable

      contracts/StakingPool.sol:49 sev: critical

      Root cause

      withdraw() sets indexStar == indexEnd; stake() stores at the current indexEnd and increments afterward; unstake() uses a strict index > indexStar predicate, making the stake at exactly indexStar unrecoverable.

      Impact

      Any user who interacts with the pool after an admin withdraw() will lose their deposit immediately on stake(). The funds remain inside the contract but are unreachable to the user, and reachable only to the owner via the next withdraw().

      Description

      After withdraw() is called, indexStar == indexEnd. The next stake() stores the new stake at the index equal to indexStar, but unstake() requires index > indexStar, so the newly-created stake fails forever.

      Codex harness (GPT-5.5)

      withdraw() skips the next stake, allowing the first post-withdraw depositor's funds to be taken without compensation

      contracts/StakingPool.sol:52 sev: high

      Root cause

      StakingPool uses indexEnd as the next issue id to be written, while active issue records are considered only when their id is strictly greater than indexStar. withdraw() sets indexStar = indexEnd, but because indexEnd is the next unused id, this makes the first future stake be written at exactly issues[indexStar] and excluded by the index > indexStar checks in unstake() and swap().

      Impact

      The first post-withdraw depositor cannot unstake and does not receive share tokens during swaps. A share holder can swap against pendingLiquidation and receive the depositor's issue tokens while the loop never transfers share tokens to the skipped depositor, leaving the paid shares stranded in the pool. The same failure happens after every owner withdrawal.

      Description

      A normal owner withdrawal permanently excludes the next stake from all staking accounting. The excluded depositor cannot unstake and will not receive share tokens if their issue tokens are later swapped out, causing direct loss of the deposited issue tokens.

      Pashov Skills

      StakingPool sev: critical

      Description

      withdraw() can leave the next stake locked at index == indexStar and drainable by a later swap. When withdraw() sets indexStar = indexEnd without advancing indexEnd, the next stake() writes to issues[indexEnd], which is equal to indexStar. Both unstake() and the swap loop use strict > comparisons, so that stake is skipped even though pendingLiquidation is increased. A later swap can satisfy the pendingLiquidation check, fail to consume the skipped stake in the loop, and still transfer the requested issue tokens to the swap caller, leaving the staker with an orphaned position and losing the deposited tokens.

    • Centralization risks chateau-4 · 3 writeups high

      V12

      Owner drains redemption backing

      contracts/VaultPool.sol:31 sev: high

      Root cause

      VaultPool.withdraw() is an unrestricted owner-only full-balance sweep of the same issueToken reserve used to satisfy share redemptions, and the owner also controls the pause state of the only redemption path.

      Impact

      The vault owner can extract the entire issueToken reserve in one transaction, eliminating the assets users expect to receive for burned shares. Share holders are then unable to redeem through the normal path because the vault is paused and no longer holds the backing balance used for payout calculation.

      Description

      VaultPool.withdraw() reads the vault’s entire issueToken balance and transfers that full amount to msg.sender, which is restricted only by onlyOwner. The function has no amount cap, timelock, destination constraint, outstanding-share check, or accounting reconciliation before removing the reserve used by reedem(). reedem() calculates user payouts from issueToken.balanceOf(address(this)), so draining that balance removes the backing for all outstanding share redemptions. The same owner can also pause redemptions directly, and withdraw() pauses the vault after the transfer, preventing users from racing to redeem once the reserve has been extracted. A malicious or compromised vault owner can therefore take all redemption liquidity and leave share holders with unredeemable or paused shares.

      Claude Code harness (Opus 4.7)

      StakingPool.withdraw() is an unbounded admin rug‑pull that also locks out users

      contracts/StakingPool.sol:151 sev: critical

      Root cause

      The owner can call withdraw() at any time and: (1) drains the entire issueToken balance of the contract — including funds that users deposited via stake() and never authorized the owner to remove; (2) sets indexStar = indexEnd so that the predicate index > indexStar used inside unstake() and getStakingInfo() becomes false for every existing user issue.

      Impact

      Total loss of all staked funds. The contract's behavior is a textbook rug‑pull — the modifier onlyOwner is the only thing standing between every staker and the loss of all their issueToken. No timelock, no liquidation accounting, no whitelist of recoverable assets.

      Description

      The owner can call withdraw() at any time to drain the entire issueToken balance, including user deposits, and set indexStar = indexEnd, making existing user stakes fail the index > indexStar predicate in unstake() and getStakingInfo().

      Pashov Skills

      StakingPool sev: critical

      Description

      StakingPool and VaultPool expose broad owner-only privileges: in StakingPool the owner can withdraw all issueToken liquidity and call setRate to change the redeemToken/issueToken swap rate; in VaultPool the owner can withdraw all liquidity and _pause the contract. A single privileged key can therefore drain liquidity or manipulate swap parameters.

  • Cove Solidity Yearn Cove basket manager. Tasks 7 V12 3/7 Claude 2/7 Codex 2/7 Pashov 1/7
    Read the audit report
    • Lack of access control in requestDeposit cove-1 · 3 writeups critical

      V12

      Unrestricted deposit owner theft

      src/BasketToken.sol:231 sev: critical

      Root cause

      requestDeposit binds asset movement to owner but binds the resulting claim to controller without authenticating that the caller is authorized by owner. The implementation relies on ERC20 allowance to the vault even though that allowance does not authorize arbitrary third parties to choose the deposit controller.

      Impact

      An attacker can steal any base assets that a victim has approved to the basket token by creating a deposit request with owner set to the victim and controller set to the attacker. Once the request is fulfilled, the attacker claims the resulting shares and can redeem or transfer them, leaving the victim with no corresponding claim.

      Description

      requestDeposit accepts an arbitrary owner and controller but never verifies that msg.sender is the owner or an approved operator for that owner. The function records the deposit under the attacker-chosen controller, emits the request, and then pulls base assets from owner using the vault's ERC20 allowance. After the basket manager fulfills the request, the controller authorization in deposit lets that controller claim the fulfilled shares to any receiver. This means a standing approval from a user to the basket token can be converted into attacker-owned basket shares without any user action in that transaction. The adjacent ERC7540 interface explicitly documents that ERC20 allowance alone is not enough authorization for requestDeposit, matching the missing trust-boundary check in the implementation.

      Claude Code harness (Opus 4.7)

      BasketToken.requestDeposit steals tokens from any user who has approved the basket

      src/BasketToken.sol:231 sev: critical

      Root cause

      requestDeposit(uint256 assets, address controller, address owner) pulls the underlying asset **from owner**, but never verifies that msg.sender is owner or an approved operator/spender of owner.

      Impact

      Complete theft of any depositor's approved underlying balance, repeatable for every basket the user has approved.

      Description

      BasketToken.requestDeposit pulls underlying assets from an arbitrary owner without verifying caller authorization, despite ERC-7540 requiring owner authorization beyond ERC-20 approval.

      Codex harness (GPT-5.5)

      Anyone can steal approved deposit assets by choosing themselves as the request controller

      src/BasketToken.sol:253 sev: critical

      Root cause

      BasketToken.requestDeposit transfers assets from the supplied owner but never checks that msg.sender is the owner or an approved operator for that owner. The implementation violates the ERC-7540 requirement that owner MUST be msg.sender unless some unspecified explicit approval is given by the caller.

      Impact

      Any user who has approved the BasketToken for the underlying asset can have those assets stolen by an attacker who chooses themselves as the request controller and later claims the fulfilled basket shares.

      Description

      BasketToken.requestDeposit transfers assets from the supplied owner but never checks that msg.sender is the owner or an approved operator for that owner. The function records the deposit under the arbitrary controller parameter, then pulls assets from owner using the vault's asset allowance. This means any user who has approved the BasketToken for the underlying asset can have those assets stolen: Alice approves the basket, an attacker calls requestDeposit(amount, attacker, Alice), the BasketToken pulls assets from Alice but credits the pending request to the attacker, and after fulfillment the attacker can claim basket shares. The implementation violates the local ERC-7540 interface requirement that owner MUST be msg.sender unless some unspecified explicit approval is given by the caller, and ERC-20 token approval alone is not enough.

    • Underflow when calculating basket balances cove-2 critical
    • Missing rebalance-status check in updateBitFlag() leads to incorrect rebalancing cove-3 · 2 writeups high

      V12

      Active Rebalance Asset Mutation

      src/libraries/BasketManagerUtils.sol:323 sev: low

      Root cause

      updateBitFlag() lacks the Status.NOT_STARTED guard used by other critical configuration setters and mutates basketAssets/BasketToken.bitFlag without invalidating the active rebalance snapshot.

      Impact

      A privileged bit-flag update during an active rebalance can make completion use different basket assets than the rebalance proposal used. The resulting mismatch can leave the rebalance stuck or finalize accounting under target weights that no longer describe the basket’s current asset set.

      Description

      A basket’s asset universe can be changed while a rebalance is already active. proposeRebalance() records only keccak256(abi.encode(baskets, basketTargetWeights)), so the committed rebalance state does not bind the basketAssets array or the basket bit flag that existed when the proposal was prepared. updateBitFlag() is callable by the timelock during any rebalance status and immediately replaces _bmStorage.basketAssets[basket] and the BasketToken bit flag. Later proposeTokenSwap() and completeRebalance() validate the old basket/target-weight hash but initialize balances and target checks from the current asset list. This lets one rebalance lifecycle mix stale target-weight assumptions with a new resource universe, causing token validation, target checks, or finalization to operate on assets that were not part of the original proposal snapshot.

      Pashov Skills

      BasketManager sev: medium

      Description

      updateBitFlag can be called during an active rebalance because it lacks the MustWaitForRebalanceToComplete guard used by analogous configuration functions such as setSwapFee, setTokenSwapAdapter, and setManagementFee. If it runs mid-epoch, the live basketAssets[basket] array can diverge from the hash-committed basketTargetWeights[i].length; _isTargetWeightMet then iterates only over proposedTargetWeights.length and silently skips the weight check for newly added assets.

    • Incorrect swap-fee calculation on feeOnBuy cove-4 high
    • Management-fee calculation results in lower effective rate cove-5 high
    • Missing mapping update in BasketToken.updateBitFlag() causes rebalancing failure cove-6 · 3 writeups medium

      V12

      Bitflag Expansion Corrupts Indexes

      src/libraries/BasketManagerUtils.sol:202 sev: high

      Root cause

      updateBitFlag() mutates the basket asset list without rebuilding the derived basketAssetToIndexPlusOne and basketTokenToBaseAssetIndexPlusOne state that other manager flows trust.

      Impact

      A normal asset-universe expansion can corrupt basket accounting by crediting deposits to the wrong underlying asset, leaving the manager with real base tokens but an inflated balance for another asset. It can also make future rebalances involving the new asset impossible, freezing the basket in a state that requires privileged repair.

      Description

      BasketManager.updateBitFlag() replaces a basket’s basketAssets array and calls BasketToken.setBitFlag(), but it does not rebuild the per-basket asset index mapping or the stored base-asset index. Those indexes are initialized during basket creation by _setBaseAssetIndex() and the basketAssetToIndexPlusOne loop, then later trusted by deposit processing and trade validation. After an inclusive bit-flag expansion, any newly added asset has no basketAssetToIndexPlusOne entry, and existing assets can have stale local indexes if the new asset appears earlier in the registry ordering. If the stale base-asset index points to a different asset, pending base-asset deposits are transferred from the basket token but accounted into the wrong basketBalanceOf slot. If a rebalance attempts to trade a newly added asset, the missing index causes basketTokenToRebalanceAssetToIndex() to revert, blocking the intended expansion path.

      Claude Code harness (Opus 4.7)

      BasketManager.updateBitFlag does not update basketAssetToIndexPlusOne

      src/BasketManager.sol:460 sev: high

      Root cause

      updateBitFlag replaces basketAssets[basket] with the new asset list, but does not repopulate the basketAssetToIndexPlusOne[basket][asset] mapping for newly added assets.

      Impact

      Internal or external trades involving a newly added asset revert, making added assets unusable and potentially leaving the basket unable to fulfill redemptions if a base asset changes after an upgrade.

      Description

      After a basket bitFlag update, newly added assets are not indexed in basketAssetToIndexPlusOne, causing trade-processing paths involving those assets to revert.

      Codex harness (GPT-5.5)

      updateBitFlag corrupts basket asset accounting after asset-set changes

      src/BasketManager.sol:203 sev: high

      Root cause

      BasketManager.updateBitFlag replaces basketAssets[basket] and calls BasketToken.setBitFlag, but does not rebuild all derived basket metadata: basketAssetToIndexPlusOne and basketTokenToBaseAssetIndexPlusOne.

      Impact

      The basket can be broken after asset-set changes: rebalances involving newly added assets can revert, trades involving moved assets can use wrong balance slots, base-asset deposits can be accounted as a different token, and the resulting corrupted accounting can freeze rebalancing and redemptions or create insolvency that token transfers cannot satisfy.

      Description

      BasketManager.updateBitFlag replaces basketAssets[basket] and calls BasketToken.setBitFlag, but it does not rebuild the per-asset index mapping or the stored base-asset index that the rebalance and deposit logic use. The update path only writes the new asset array and updates the token bitflag, while the initial basket creation path sets the derived metadata. Newly added assets may be present in basketAssets but absent from basketAssetToIndexPlusOne, existing assets may move to different positions while their old index mapping remains, and the stored base-asset index can become stale. In an example expansion from [A, C] to [A, B, C], B has a zero mapping, C points to its old index, and if C is the base asset, deposits may be credited to B.

    • Potential price manipulation via read-only reentrancy in BasketToken.proRataRedeem() cove-7 medium
  • Dojoswap Rust CosmWasm DEX (Injective). Tasks 2 V12 2/2 Claude 2/2 Codex 2/2 Pashov —
    Read the audit report
    • Deposit amount is not validated against message funds dojoswap-1 · 3 writeups critical

      V12

      Deposits Credit Unsupplied Amounts

      contracts/launchpad/src/msg.rs:23 sev: critical

      Root cause

      deposit trusts the message amount as payment accounting without binding it to the actual info.funds[0].amount received by the contract.

      Impact

      A participant can underpay the native raising denom while receiving allocation as if they paid the declared amount. With a declared amount up to the raising target and only dust sent, the attacker can claim a disproportionate or complete share of the prefunded offering tokens, diluting or draining allocations owed to honest participants.

      Description

      ExecuteMsg::Deposit accepts an attacker-controlled amount, but deposit validates only that exactly one native fund of the configured denom was attached. It never checks that info.funds[0].amount equals the declared amount, then credits user.amount and state.total_amount by that declared value. harvest later calculates the user's offering-token allocation and refund from the stored user.amount and state.total_amount, not from the actual native tokens received. The separate missing-user initialization bug blocks fresh deposits in the current code, but this accounting defect is an independent trust-boundary failure in the same externally reachable deposit/harvest flow and becomes directly exploitable for any initialized user record or after the initialization bug is corrected.

      Claude Code harness (Opus 4.7)

      Launchpad deposit does not verify the amount of native funds sent

      contracts/launchpad/src/contract.rs:154 sev: critical

      Root cause

      The native funds validation checks only info.funds.len() and info.funds[0].denom, not that info.funds[0].amount equals the user-supplied amount.

      Impact

      A single permissionless tx steals (a) every offering token reserved for the sale and (b) all native funds genuine depositors contributed.

      Description

      deposit only checks the denom of the incoming funds, never the amount. amount is a user-supplied argument from ExecuteMsg::Deposit { amount }, and there is no comparison such as info.funds[0].amount == amount. An attacker can send a tiny non-zero amount of the correct denom and pass a huge amount to capture offering tokens and drain genuine depositors' raising-denom funds via refund logic.

      Codex harness (GPT-5.5)

      Launchpad credits the requested deposit amount instead of the funds actually sent

      audit/contracts/launchpad/src/contract.rs:174 sev: critical

      Root cause

      The contract trusts the caller-supplied amount parameter for accounting and does not verify it equals the actual native funds sent.

      Impact

      If a user record exists, a participant can over-credit their contribution with little or no payment and then claim offering tokens and refunds based on fabricated accounting. This can steal the sale allocation from honest users and make refund accounting insolvent.

      Description

      The deposit path accepts an arbitrary amount parameter and only checks that exactly one coin of the configured denom was sent. It never checks info.funds[0].amount == amount. The credited user amount and global total_amount are both increased by the caller-supplied amount, not by the native funds received by the contract.

    • FEE_COLLECTOR address can reconfigure pair assets and enable drains dojoswap-2 · 3 writeups high

      V12

      Privileged asset rebinding drains reserves

      contracts/dojoswap_pair/src/contract.rs:39 sev: high

      Root cause

      admin_configure grants FEE_COLLECTOR arbitrary write access to PAIR_INFO.asset_infos for live pairs. The code lacks an invariant that configured assets are immutable after instantiation or only change under a safe empty-pool migration path.

      Impact

      Control or compromise of the fee-collector key becomes effective reserve control over every deployed pair. Existing LP reserves can be drained or made inaccessible through ordinary pair entrypoints after the metadata is rebound.

      Description

      ExecuteMsg::AdminConfigure reaches admin_configure, which is gated only by equality with the hardcoded FEE_COLLECTOR address. Once that address calls the function, the contract rebuilds and saves PAIR_INFO with arbitrary caller-supplied assets and asset_decimals, preserving only the LP token address. The function does not require the new assets to match the pair’s original assets, does not require reserves to be empty, and does not coordinate with the factory registry. Subsequent liquidity withdrawals and swaps query balances using the mutated PAIR_INFO, so a live pool can be rebound to an attacker-controlled worthless token on one side while retaining the valuable reserve on the other side. The attacker can then use normal swap or LP redemption paths against the rebased metadata to extract value or strand the original assets outside the pair API.

      Claude Code harness (Opus 4.7)

      Pair admin_configure can re‑point the pair to arbitrary assets, freezing user funds and enabling theft

      contracts/dojoswap_pair/src/contract.rs:147 sev: high

      Root cause

      A hard-coded privileged address can rewrite pair asset metadata without constraints, timelock, liquidity checks, or rotation mechanism.

      Impact

      Total loss or freeze of all LP positions; depending on attacker setup, exfiltration of pair balances.

      Description

      admin_configure is gated only by equality against a hard-coded fee collector address and rewrites asset_infos and asset_decimals on any pair. There is no constraint that new assets relate to existing assets, no zero-liquidity check, no timelock, and no LP-holder protection. If the privileged key is compromised, an attacker can repoint pairs to assets with zero balance so LP withdrawals burn shares for zero refund, or to attacker-controlled tokens to manipulate pricing and balances.

      Codex harness (GPT-5.5)

      Pair fee-collector address can arbitrarily replace pair assets and freeze liquidity

      audit/contracts/dojoswap_pair/src/contract.rs:40 sev: high

      Root cause

      A hard-coded fee-collector address has unrestricted authority to rewrite pair asset configuration without governance controls or validation that assets match the existing pool.

      Impact

      A single hard-coded fee-collector address has unilateral authority to rewrite the asset definitions of any pair. If that key is compromised or acts maliciously, LPs can be forced into withdrawals of the wrong assets and the original pool assets can be frozen in the contract.

      Description

      AdminConfigure on the pair contract checks only that info.sender equals the hard-coded FEE_COLLECTOR, then overwrites PAIR_INFO.asset_infos and asset_decimals. There is no factory-owner check, no pair-admin check, no duplicate-asset check, and no restriction that the new assets match the pair's existing balances. Because swaps, pool queries, and withdrawals all use PAIR_INFO, changing it changes which balances are considered pool reserves and which assets LPs receive on withdrawal.

  • Ebridge Solidity aelf bridge to Ethereum. Tasks 3 V12 2/3 Claude 2/3 Codex 0/3 Pashov 2/3
    Read the audit report
    • MerkleTree usage complexity ebridge-1 · 1 writeup high

      Pashov Skills

      MerkleTreeImplementation sev: medium

      Description

      The cross-chain receipt flow stores all Merkle leaves on-chain: transmit records each receiptHash via IMerkleTree(merkleTree).recordMerkleTree and persists leafNodeIndex, building and keeping the full tree in contract storage instead of constructing leaf paths off-chain and only verifying a path on-chain. This deviates from standard Merkle usage and adds significant per-transmit storage and gas overhead.

    • Front-running gas attack can cancel transaction ebridge-2 · 2 writeups medium

      V12

      Failed Calls Consume Governance Transactions

      contracts/MultiSigWallet.sol:182 sev: medium

      Root cause

      executeTransaction() performs the state transition to executed = true before verifying that the external call succeeded and does not reset the flag in the failure branch.

      Impact

      A member who supplies or times the final confirmation can permanently consume a specific approved governance transaction without executing its intended action whenever the target currently reverts with a standard string error. Operators must submit and re-approve a replacement transaction, which can delay or block urgent bridge configuration or recovery actions during incidents.

      Description

      executeTransaction() marks a multisig transaction as executed before making the requested external call. When the target call returns success == false, the function emits ExecutionFailure but never restores transaction.executed to false. The wallet therefore treats a failed governance action as completed and the notExecuted modifier blocks any later retry of the same transaction after the target precondition is fixed. This is reachable for normal bridge administration calls because confirmTransaction() automatically invokes executeTransaction() as soon as quorum is reached, and protected bridge functions such as addToken(), removeToken(), and restart() can revert on state-dependent conditions.

      Claude Code harness (Opus 4.7)

      MultiSigWallet.executeTransaction sets transaction.executed = true before the call and then relies on rollback behavior.

      Root cause

      executeTransaction marks a transaction as executed before performing the external call, so final execution state depends on rollback behavior instead of being set only after a confirmed successful call.

      Impact

      If the call succeeds, the executed flag persists. If it reverts with non-string data, the flag is rolled back along with the rest of the transaction; see H‑03. This is a subtle UX point.

      Description

      executeTransaction sets transaction.executed = true before calling the destination, and rollback behavior differs depending on whether the call succeeds or reverts.

    • Special considerations for seed liquidity ebridge-3 · 3 writeups medium

      V12

      Unrestricted Deposits Lock User Funds

      contracts/BridgeInImplementation.sol:207 sev: high

      Root cause

      deposit() exposes an admin-style liquidity provisioning flow to arbitrary callers while withdraw() and depositAmount are global owner-controlled accounting with no per-depositor receipt or claim state.

      Impact

      A user or integration routed through deposit() permanently loses direct control of the supplied tokens and receives no bridge receipt to claim against. The owner can later withdraw those tokens to any receiver, or the liquidity can remain in BridgeOut backing unrelated bridge-out claims.

      Description

      deposit() is an externally callable seed-liquidity path with no onlyOwner, onlyWallet, pause, or receipt-creation guard. It accepts any supported tokenKey, pulls amount from msg.sender, increments the single global depositAmount[tokenKey], approves bridgeOut, and forwards the same tokens into BridgeOut.deposit(). Unlike createReceipt() and generateReceipt(), this path does not write a Receipt, does not index a receipt under the depositor, and does not create any per-depositor claim. The only recovery path for this accounting bucket is withdraw(), which is onlyOwner and can send the tokens to an arbitrary receiverAddress, so externally supplied funds become owner-controlled liquidity rather than user-owned bridge receipts.

      Claude Code harness (Opus 4.7)

      Liquidity providers calling BridgeIn.deposit permanently lose ownership of the deposit (sole withdrawer is onlyOwner)

      contracts/BridgeInImplementation.sol:301 sev: high

      Root cause

      deposit is permissionless and pulls ERC20 from msg.sender into the bridge. withdraw is onlyOwner and may direct funds to any receiverAddress. There is no per-user accounting of depositAmount; once a user deposits, the funds are entirely under the proxy owner's control.

      Impact

      Users who deposit cannot retrieve their funds and the owner can sweep deposits to an arbitrary address. A compromised or malicious proxy owner can drain all deposited liquidity at any time without requiring an implementation upgrade.

      Description

      Permissionless deposits into BridgeIn have no per-user accounting, while withdrawals are owner-only and can send funds to any receiver.

      Pashov Skills

      BridgeInImplementation sev: medium

      Description

      BridgeIn.deposit is permissionless and creates an owner-extractable liquidity pool. deposit(bytes32 tokenKey, address token, uint256 amount) has no access modifier: any caller can transfer tokens into BridgeIn, which credits depositAmount[tokenKey] and forwards to BridgeOut.deposit. No share token is minted, no receipt is emitted, and only the proxy owner (withdraw is onlyOwner) can later pull those tokens out to an arbitrary receiverAddress. A malicious frontend or user error can therefore route user funds straight into a pool only the owner controls.

  • Etherfi Solidity EtherFi liquid restaking. Tasks 9 V12 4/9 Claude 2/9 Codex 1/9 Pashov 0/9
    Read the audit report
    • Repeated validator IDs could be passed in batchRevertExitRequest etherfi-1 critical
    • Repeated validator IDs could be passed in batchSendExitRequest etherfi-2 · 1 writeup critical

      V12

      Duplicate Exit Requests Poison Safes

      src/EtherFiNodesManager.sol:129 sev: medium

      Root cause

      Exit requests are counted as a safe-level integer but tracked per validator as a single timestamp, and duplicate requests are not made idempotent. The recycling path reuses a safe without asserting or resetting numExitRequestsByTnft to zero.

      Impact

      A malicious validator owner can poison a withdrawal safe before it is recycled, causing future unrelated validators assigned that safe to lose normal reward-withdrawal liveness. Funds are not directly stolen, but rewards can be stuck behind a forced full-exit path and normal maintenance withdrawals can be permanently blocked for the recycled safe.

      Description

      batchSendExitRequest() does not check whether a validator already has an outstanding exit request before calling updateNumExitRequests(1, 0) and overwriting the single exitRequestTimestamp. A TNFT owner can therefore call the function repeatedly for the same validator, causing the associated safe’s numExitRequestsByTnft counter to exceed the one timestamp recorded in validatorInfos. When that validator is later fully withdrawn, EtherFiNode.unRegisterValidator() decrements the counter only once if the validator info has a nonzero timestamp, and it does not clear the counter when the safe’s associated validator count reaches zero. EtherFiNodesManager._unRegisterValidator() then pushes the now-empty safe into unusedWithdrawalSafes, making it available for recycling with the stale counter still set. Any future validator assigned that recycled safe will hit _getTotalRewardsPayoutsFromSafe()’s numExitRequestsByTnft() == 0 requirement and be unable to skim rewards until it exits.

    • BNFT holder could cancel the deposit after processNodeExit is called etherfi-3 · 2 writeups critical

      V12

      Exited cancellations recycle funded safes

      src/LiquidityPool.sol:381 sev: high

      Root cause

      _cancelDeposit() can route an EXITED validator through nodesManager.unregisterValidator() without the settlement steps enforced by fullWithdraw(). Empty-safe recycling then keys only on numAssociatedValidators() and does not require the safe, EigenPod, or delayed-withdrawal balances to be zero.

      Impact

      A BNFT-flow staker can cancel after exit and cause a funded withdrawal safe to be returned to the reusable pool without paying the exited validator's proceeds through the full-withdraw path. The residual balance can then block reward withdrawals for the next validator or be attributed to later recipients when the recycled safe is eventually paid out, causing conditional fund misdirection or freeze.

      Description

      The cancellation path can unregister validators without restricting the current validator phase to a pre-registration or pre-approval state. When cancellation reaches an EXITED validator, _unRegisterValidator() treats it like a full-withdraw cleanup by setting the phase to FULLY_WITHDRAWN, deleting the validator-to-safe mapping, and pushing the safe to unusedWithdrawalSafes if its association count becomes zero. Unlike fullWithdraw(), this path does not claim outstanding EigenLayer withdrawals, calculate full-withdrawal payouts, distribute safe funds, or burn the NFTs as part of settlement. EtherFiNode.unRegisterValidator() only resets restakingObservedExitBlock and isRestakingEnabled for an empty safe, leaving native ETH and any EigenLayer-side residuals attached to the safe address. When recycling is enabled, allocateEtherFiNode() can assign that funded safe to a later validator, and later payout/accounting consumers read the recycled safe's current execution-layer balance as if it belonged to the new lifecycle.

      Claude Code harness (Opus 4.7)

      Cancelling an EXITED validator via the bNFT cancel path locks the safe's principal and breaks the full-withdrawal flow

      src/LiquidityPool.sol:389 sev: high

      Root cause

      Cancel logic branches on WAITING_FOR_APPROVAL vs everything else and does not restrict cancellations to pre-live phases; _unRegisterValidator permits EXITED → FULLY_WITHDRAWN and deletes the node mapping without payout.

      Impact

      Direct loss or locking of LP T-NFT principal (~30 ETH per validator), safe recycling can siphon inherited ETH to the next validator's stakeholders, and full/partial withdraws permanently revert for the original validator.

      Description

      The bNFT cancel path can be used on an EXITED validator, transitioning it to FULLY_WITHDRAWN and unregistering it without distributing the safe's ETH.

    • Malicious users could use the ETH of legitimate users and mint themselves NFTs etherfi-4 · 1 writeup critical

      V12

      BNFT Flow Steals Deposits

      src/StakingManager.sol:104 sev: critical

      Root cause

      batchRegisterValidators(bytes32,uint256[],DepositData[]) lacks a sourceOfFund == DELEGATED_STAKING check or per-validator escrow accounting, so BNFT-flow stakers can invoke the delegated 32 ETH registration path against shared contract balance.

      Impact

      An attacker can pay only the BNFT-flow upfront amount, wait for delegated-staking ETH to be parked in StakingManager, and register a validator that they own using 32 ETH from those parked deposits. The honest delegated stakers’ later registrations or cancellations become underfunded, while the attacker controls both NFT claims to the validator principal when it exits.

      Description

      The unrestricted 32 ETH registration overload can be used for validator IDs that were created through the BNFT/liquidity-pool flow. LiquidityPool._batchDeposit() calls the non-payable staking-manager overload and records the BNFT holder as _staker, but the 2 ETH BNFT deposit stays in the liquidity pool and no 32 ETH is transferred to StakingManager. The public StakingManager.batchRegisterValidators(bytes32,uint256[],DepositData[]) later only checks that bidIdToStakerInfo[_validatorId].staker == msg.sender, so that same BNFT holder can call it directly for the BNFT-flow validator ID. _registerValidator() then deposits 32 ETH from the StakingManager contract balance, sets the validator LIVE because the TNFT recipient is the caller instead of the liquidity pool, and mints both NFTs to the caller. Any ETH parked in StakingManager for honest delegated-staking reservations is fungible contract balance and can be consumed by this direct registration, letting the BNFT holder fund their own validator with other users’ 32 ETH deposits.

    • ETH sent to wrong address on cancellation etherfi-5 · 2 writeups critical

      V12

      Zero-Deposit Cancellation Refund

      src/LiquidityPool.sol:298 sev: high

      Root cause

      _batchDeposit() records msg.sender as the staker even for the zero-deposit LP BNFT mode, while _batchCancelDeposit() refunds fixed bond amounts instead of the amount actually contributed by that staker.

      Impact

      A registered BNFT holder can extract 2 ETH per validator without posting the corresponding bond, limited by available active bids and pool liquidity. Repeating the flow drains ETH that backs eETH holders and can make the pool insolvent or unable to satisfy withdrawals.

      Description

      When isLpBnftHolder mode is enabled, batchDepositWithLiquidityPoolAsBnftHolder() calls _batchDeposit() with _stakerDepositAmountPerValidator equal to zero. _batchDeposit() still records msg.sender as the BNFT staker in StakingManager, so a registered BNFT holder can allocate validators without sending the normal 2 ETH bond. The cancellation path does not remember how much that staker actually posted and instead refunds a hard-coded 2 ether for each validator still in STAKE_DEPOSITED, or 1 ether once it is in WAITING_FOR_APPROVAL. Because StakingManager authorizes cancellation using the same recorded staker, the zero-deposit caller can immediately cancel and receive ETH from the pool. This creates a direct drain whenever the LP-as-BNFT-holder mode is enabled while any externally callable registered holder can reach these functions.

      Claude Code harness (Opus 4.7)

      LP-BNFT mode: registered BNFT holder can extract ETH via batchCancelDeposit without ever staking anything

      src/LiquidityPool.sol:268 sev: high

      Root cause

      In LP-bNFT mode the staker contributes 0 ETH, yet StakingManager.bidIdToStakerInfo records the caller as staker and LiquidityPool._batchCancelDeposit refunds that recorded staker from LP funds regardless of contribution.

      Impact

      Direct theft of LP capital; attacker can repeat because auction bids are re-entered.

      Description

      In LP-as-bNFT-holder mode, a registered BNFT holder can initiate deposits with zero ETH but later cancel and receive 1–2 ETH per validator from LP funds.

    • Wrong rewards calculation due to numAssociatedValidators etherfi-6 high
    • The BNFT holder is compared with an incorrect address etherfi-7 medium
    • Queued withdrawals are not claimed by forcePartialWithdraw etherfi-8 · 1 writeup medium

      Codex harness (GPT-5.5)

      Bounded EigenLayer claims can make restaked partial withdrawals revert after dust-queue griefing

      src/EtherFiNodesManager.sol:202 sev: low

      Root cause

      For restaked validators, partialWithdraw() first claims only up to maxEigenlayerWithdrawals queued withdrawals. The payout calculation then includes every still-claimable delayed withdrawal, not just ETH that was actually claimed into the safe. Attackers can create such entries because queueRestakedWithdrawal() and batch queueing are public.

      Impact

      If more than maxEigenlayerWithdrawals withdrawals remain claimable, _getTotalRewardsPayoutsFromSafe() can compute payouts larger than the ETH currently in the safe. withdrawFunds() then attempts to send funds the safe does not yet hold and reverts. This can force additional maintenance transactions and potentially delay reward skimming; it is a liveness and gas grief rather than permanent fund loss.

      Description

      Attackers can force additional maintenance transactions, and potentially delay reward skimming, by creating more claimable delayed withdrawals than the manager claims before computing payouts.

    • Deposit cancellation may fail if the etherFiNode version is not updated etherfi-9 medium
  • Euler FEE Flow Solidity Periodic fee-revenue auction system. Tasks 1 V12 0/1 Claude 0/1 Codex 0/1 Pashov 0/1
    Read the audit report
    • Front-running of the buy function euler-fee-flow-1 medium
  • Gammaswap Staking Solidity Gammaswap staking and rewards. Tasks 2 V12 2/2 Claude 1/2 Codex 1/2 Pashov 1/2
    Read the audit report
    • Vester incorrect burn gammaswap-staking-1 · 3 writeups high

      V12

      Vesting Burns User Wallet Tokens

      contracts/VesterNoReserve.sol:240 sev: high

      Root cause

      VesterNoReserve._updateVesting passes _account to IRestrictedToken(esToken).burn even though _deposit escrowed the tokens in address(this). The reserve-based Vester counterpart burns from address(this), showing the intended accounting target for vested escrow tokens.

      Impact

      Users' vesting positions become unclaimable or unwithdrawable after vesting begins unless they hold additional liquid escrow tokens equal to the vested amount. If they do hold those tokens, the contract burns their extra wallet balance and strands the originally deposited escrow tokens in the vester, causing direct value loss and a persistent accounting mismatch.

      Description

      VesterNoReserve escrows a user's esToken during _deposit by transferring the deposited amount from the account into the vesting contract and minting non-transferable vesting shares. When vesting advances, _updateVesting burns vesting shares and marks the amount claimable, but it calls IRestrictedToken(esToken).burn(_account, amount) instead of burning the escrowed tokens held by the vesting contract. The configured restricted token burn function burns from the address passed to it, so the vested amount is taken from the user's current wallet balance rather than from the escrow balance. Public router flows reach this path through vestEsGsb, withdrawEsGsb, and aggregate claim, so a user who has no additional liquid esGsb cannot claim vested GS or withdraw once any nonzero amount has vested, while a user who does have liquid esGsb loses those extra tokens and leaves the originally escrowed tokens stranded in the vester.

      Claude Code harness (Opus 4.7)

      VesterNoReserve._updateVesting burns esGSb from the user instead of from the contract, bricking the vester (or stealing user wallet balance)

      contracts/VesterNoReserve.sol:324 sev: high

      Root cause

      The function calls IRestrictedToken(esToken).burn(_account, amount) after deposits have already transferred the user's esGSb to address(this).

      Impact

      Loss of funds and/or permanent denial-of-service on the entire esGSb vesting flow. Vesting settlement reverts when the user has no esGSb wallet balance; if the user has acquired more esGSb, those wallet tokens are burned while deposited esGSb remains orphaned in the contract, and withdrawal later effectively double-charges the user.

      Description

      VesterNoReserve._updateVesting burns esGSb from _account even though deposited esGSb was transferred into the vester contract. The correct behavior, as in Vester.sol, is to burn from address(this).

      Codex harness (GPT-5.5)

      VesterNoReserve burns vested esGSb from the user instead of the escrow, freezing deposits and destroying unrelated user balances

      sev: high

      Root cause

      VesterNoReserve burns vested escrow tokens from _account even though the deposited esGSb is held by the vester contract.

      Impact

      Borrower vesting can become permanently unclaimable for normal users, and users who do have liquid esGSb are charged twice: once when depositing into the vester and again when vested amounts are burned from their wallet/staked balance. The impact is direct borrower reward loss and escrow token freezes for the high-priority VesterNoReserve path used by StakingRouter.vestEsGsb.

      Description

      VesterNoReserve._deposit transfers the user's esGSb into the vester contract and mints non-transferable vesting shares to the user:

      - contracts/VesterNoReserve.sol:240-247

      However, when vesting advances, _updateVesting burns the underlying escrow token from _account:

      - contracts/VesterNoReserve.sol:312-324

      This differs from Vester, which correctly burns vested escrow from address(this) after the escrow has been deposited:

      - contracts/Vester.sol:375-387

      Because VesterNoReserve already holds the deposited esGSb, burning from _account has two bad outcomes:

      1. If the user has no separate esGSb balance outside the vester, claim() and withdraw() revert during IRestrictedToken(esToken).burn(_account, amount). The originally deposited esGSb remains in the vester and the user cannot claim vested GS or cancel vesting.

      2. If the user does have esGSb elsewhere, the vester burns that unrelated user balance while leaving the vested portion of the deposited esGSb trapped inside the vester. The user loses the deposited amount and an additional amount from their wallet/staked balance.

      The impact is direct borrower reward loss and escrow token freezes for the high-priority VesterNoReserve path used by StakingRouter.vestEsGsb.

    • Cancellation of isDepositToken still allows rewards to be claimed gammaswap-staking-2 · 2 writeups medium

      V12

      Disabled Tokens Keep Earning

      contracts/RewardTracker.sol:72 sev: high

      Root cause

      The deposit-token allowlist is used both as an admission control for new stakes and as a withdrawal precondition for existing stakes. setDepositToken lacks a nonzero-supply guard or separate deprecated-token state that would stop new deposits while still allowing withdrawals.

      Impact

      A single de-whitelist of an active token permanently freezes the affected users’ deposited GS-family or LP tokens inside the tracker. Those frozen positions also continue consuming future reward emissions, diverting rewards from active removable stake until governance restores the token flag or rewards are exhausted.

      Description

      RewardTracker.setDepositToken() can disable any whitelisted deposit token without checking whether users still have depositBalances or whether totalDepositSupply for that token is zero. The same isDepositToken flag is then required by _unstake() before a user can burn tracker shares and receive the underlying token back. Once a token is disabled, all existing deposits of that token are trapped because both unstake() and handler-mediated unstakeForAccount() route through _unstake(). The locked position still remains in stakedAmounts, balances, and totalSupply, and reward claiming does not check the deposit token allowlist, so the immobilized stake continues to receive emissions while the user has no exit path.

      Pashov Skills

      RewardTracker sev: medium

      Description

      setDepositToken(token, false) can permanently prevent existing holders from unstaking that token. _unstake requires isDepositToken[_depositToken], mirroring the staking gate, so if the owner disables a token while users still have staked balances, unstake and unstakeForAccount revert for those users. The affected funds can only be recovered through the owner-controlled StakingAdmin.execute path.

  • Hyperliquid Solidity Hyperliquid DEX bridge contracts. Tasks 2 V12 1/2 Claude 1/2 Codex 1/2 Pashov 1/2
    Read the audit report
    • Withdrawal finalization does not work hyperliquid-1 · 3 writeups high

      V12

      Withdrawals Cannot Finalize

      Bridge2.sol:60 sev: critical

      Root cause

      The contract applies nonReentrant to both the external entry point and the private helper it calls. OpenZeppelin ReentrancyGuard does not allow nested nonReentrant calls within the same execution.

      Impact

      Every valid withdrawal request becomes unclaimable through the contract’s normal flow. Deposited bridge liquidity remains locked in the contract for users attempting to exit, because finalization always reverts before the ERC20 transfer executes.

      Description

      batchedFinalizeWithdrawals() is the only externally reachable withdrawal finalization entry point, but it is marked nonReentrant and calls finalizeWithdrawal() inside its loop. finalizeWithdrawal() is also marked nonReentrant, even though it is a private helper. With OpenZeppelin ReentrancyGuard, entering the external function sets the guard before the private helper executes, so the helper’s second guard check reverts before it can mark the withdrawal finalized or transfer USDC. Because finalizeWithdrawal() is private, users cannot bypass the batched entry point to complete a pending withdrawal through another function.

      Claude Code harness (Opus 4.7)

      batchedFinalizeWithdrawals is permanently bricked by nested nonReentrant — withdrawals can never be finalized

      Bridge2.sol:284 sev: critical

      Root cause

      batchedFinalizeWithdrawals is declared nonReentrant, then iterates calling the private function finalizeWithdrawal, which is also declared nonReentrant. OpenZeppelin ReentrancyGuard uses a single _status slot, so the outer call sets _status = _ENTERED and the inner guard reverts with ReentrancyGuard: reentrant call.

      Impact

      Total loss of user-funds-out liveness. Every user withdrawal will fail at finalization, producing a critical funds-locked failure mode.

      Description

      batchedFinalizeWithdrawals calls private finalizeWithdrawal, and both are marked nonReentrant, so OpenZeppelin ReentrancyGuard reverts on the nested call. Because finalizeWithdrawal is private and only reachable through the batched function, withdrawals cannot be finalized through the documented path.

      Codex harness (GPT-5.5)

      Withdrawals cannot be finalized because the only public finalizer re-enters nonReentrant

      audit/Bridge2.sol:302 sev: high

      Root cause

      Both the external batching function and the internal/private withdrawal finalization helper are protected by the same global nonReentrant guard, causing the nested call to revert.

      Impact

      Every non-empty call to batchedFinalizeWithdrawals reverts before reaching the dispute-period check or token transfer. Since there is no other external withdrawal finalization function, users can request withdrawals successfully but can never release the USDC held by the bridge. This results in complete withdrawal liveness failure / asset freeze.

      Description

      batchedFinalizeWithdrawals is the only externally callable function that finalizes requested withdrawals. It is marked nonReentrant and calls finalizeWithdrawal for each message. However, finalizeWithdrawal is also marked nonReentrant. OpenZeppelin ReentrancyGuard uses one global guard flag for the whole contract. Once batchedFinalizeWithdrawals enters, the guard is set to _ENTERED; the nested call to finalizeWithdrawal immediately hits the same modifier and reverts with ReentrancyGuard: reentrant call.

    • Disputed actions are not blocked by validator rotation hyperliquid-2 · 1 writeup high

      Pashov Skills

      Bridge2 sev: medium

      Description

      Withdrawal signatures have no expiry and pending withdrawal requests survive validator-set rotations. The signed payload keccak256(abi.encode(msg.sender, usdc, nonce)) carries no deadline, and existing requestedWithdrawals[message] entries are not invalidated when the hot validator set rotates. If a hot key is compromised, an attacker can submit a previously signed withdrawal before the rotation is reflected on-chain, leaving the locker only the dispute window to react.

  • Maia Solidity Maia Ulysses omnichain liquidity. Tasks 13 V12 11/13 Claude 7/13 Codex 8/13 Pashov 9/13
    Read the audit report
    • Lack of the RootBridgeAgent contract verification maia-1 · 3 writeups critical

      V12

      Unrestricted Trusted Agent Creation

      src/2-audit/ulysses-omnichain/factories/ArbitrumBranchBridgeAgentFactory.sol:46 sev: critical

      Root cause

      createBridgeAgent() delegates trust establishment to the factory without authenticating the caller or validating that _rootBridgeAgentAddress is an approved root bridge agent. This combines with ArbitrumBranchBridgeAgent’s direct msg.sender executor check to let arbitrary users create port-trusted agents they can drive themselves.

      Impact

      An attacker can register a maliciously configured bridge agent and use it to mint arbitrary local hTokens or withdraw tokens held by the local port. If the port contains bridged reserves or root hToken accounting value, this provides a direct path to inflate supply and drain custodied assets.

      Description

      ArbitrumBranchBridgeAgentFactory.createBridgeAgent() is externally callable by anyone and immediately registers the newly deployed agent in the local port through IPort(localPortAddress).addBridgeAgent(newBridgeAgent). The caller controls both _newBranchRouterAddress and _rootBridgeAgentAddress, and the deployed ArbitrumBranchBridgeAgent stores the supplied root agent as its trusted inbound caller. Unlike the base branch agent, the Arbitrum variant authorizes inbound execution solely by checking msg.sender == rootBridgeAgentAddress, so an attacker can deploy a trusted branch agent with their own address as the root agent and then call anyExecute() directly. A crafted settlement payload reaches _clearToken(), which calls the port to mint hTokens or withdraw underlying assets to an attacker-controlled recipient. Because the factory-added agent is already marked as an active bridge agent in the port, those port calls pass the requiresBridgeAgent guard.

      Claude Code harness (Opus 4.7)

      BranchBridgeAgentFactory.createBridgeAgent is permissionless — anyone can register a malicious bridge agent and drain the branch port

      src/2-audit/ulysses-omnichain/factories/BranchBridgeAgentFactory.sol:70 sev: critical

      Root cause

      The factory createBridgeAgent functions lack owner, core-router, or root-side authorization, while bridge agents created by an approved factory are automatically added to the branch port whitelist.

      Impact

      An attacker can obtain a whitelisted bridge agent controlled through their router, expanding access to privileged bridge flows and combining with bridge-agent deposit redemption flaws to mint unauthorized hTokens or otherwise abuse branch port permissions.

      Description

      BranchBridgeAgentFactory.createBridgeAgent and its Arbitrum variant are permissionless, allowing anyone to deploy and whitelist a branch bridge agent with attacker-controlled routing parameters.

      Codex harness (GPT-5.5)

      Permissionless Arbitrum bridge-agent creation lets an attacker drain the branch port

      src/2-audit/ulysses-omnichain/factories/ArbitrumBranchBridgeAgentFactory.sol:46 sev: critical

      Root cause

      Permissionless bridge-agent creation combined with whitelisting in the branch port and an executor check that trusts user-supplied rootBridgeAgentAddress.

      Impact

      This breaks the core bridge trust boundary and can drain all underlying assets held by the Arbitrum branch port or mint unbacked branch hTokens.

      Description

      ArbitrumBranchBridgeAgentFactory.createBridgeAgent() is external and has no access control, yet it immediately registers the newly deployed agent as an authorized bridge agent in the branch port. The caller also supplies _rootBridgeAgentAddress. For Arbitrum agents, _requiresExecutor() only checks msg.sender == rootBridgeAgentAddress, so an attacker can set themselves as the root bridge agent and then call anyExecute() directly.

    • Missing access control for Multicall maia-2 · 3 writeups critical

      V12

      Unauthenticated signed multi-output execution

      src/2-audit/ulysses-omnichain/MulticallRootRouter.sol:263 sev: medium

      Root cause

      anyExecuteSignedDepositMultiple lacks the requiresAgent modifier used by the other router execution entrypoints, leaving a bridge-agent callback surface publicly callable.

      Impact

      An external caller can reach bridge-agent-only router behavior without passing through the bridge agent’s Anycall validation, deposit parsing, or virtual-account approval toggling flow. This enables unauthorized settlement attempts and can move or burn hTokens held by the router under attacker-chosen output parameters when the supplied account callbacks succeed.

      Description

      anyExecuteSignedDepositMultiple is the only active signed multicall execution entrypoint in this router that omits requiresAgent. The function is externally callable by any address and still executes arbitrary IVirtualAccount(userAccount).call(calls), withdraws the requested output tokens from userAccount, and invokes _approveMultipleAndCallOut. That internal helper then calls RootBridgeAgent.callOutAndBridgeMultiple from the trusted router address, crossing the RootBridgeAgent.requiresRouter boundary even though the original caller was not the bridge agent. An attacker can supply a contract that satisfies the IVirtualAccount interface or exploit router-held hToken balances to drive unauthorized outbound bridge attempts through the root router surface. The adjacent signed entrypoints require msg.sender == bridgeAgentAddress, so this function creates an inconsistent trust boundary for the same class of cross-chain signed execution.

      Codex harness (GPT-5.5)

      Missing router authentication on signed multi-deposit execution enables reentrant VirtualAccount abuse

      src/2-audit/ulysses-omnichain/MulticallRootRouter.sol:341 sev: high

      Root cause

      anyExecuteSignedDepositMultiple() lacks the requiresAgent modifier and no reentrancy guard protects the temporary VirtualAccount router approval window.

      Impact

      The reentrant call can execute additional calls and withdrawERC20() assets that were not authorized by the user's original payload, then bridge them to an attacker-controlled recipient.

      Description

      Most MulticallRootRouter bridge-agent callbacks use requiresAgent, including anyExecuteSignedDepositSingle(). anyExecuteSignedDepositMultiple() omits this modifier. During the root bridge agent's temporary approval of the router, a malicious target called by IVirtualAccount(userAccount).call(calls) can reenter the unprotected anyExecuteSignedDepositMultiple() function.

      Pashov Skills

      MulticallRootRouter sev: critical

      Description

      MulticallRootRouter.anyExecuteSignedDepositMultiple is missing requiresAgent, letting anyone drain VirtualAccounts. Every sibling (anyExecute, anyExecuteSigned, anyExecuteSignedDepositSingle, anyExecuteDepositSingle, anyExecuteDepositMultiple, anyExecuteResponse) carries requiresAgent; line 418 declares only external payable returns (...), so any address can invoke arbitrary IVirtualAccount(userAccount).call(...) and withdrawERC20 on the supplied userAccount.

    • Multitoken lacks validations maia-3 · 3 writeups critical

      V12

      Empty Deposits Mint Free Shares

      src/2-audit/erc-4626/ERC4626MultiToken.sol:54 sev: critical

      Root cause

      The deposit path does not validate assetsAmounts.length == assets.length, and convertToShares() uses a sentinel maximum value that is returned unchanged for empty input.

      Impact

      An attacker can create an enormous share balance without depositing any underlying assets. This corrupts vault accounting, dilutes all legitimate share holders, and can break any integration that values or accepts the share token as collateral or payment.

      Description

      ERC4626MultiToken.deposit() trusts the caller-supplied assetsAmounts array without requiring it to contain one entry for every configured asset. convertToShares() initializes shares to type(uint256).max and then only lowers it while iterating over assetsAmounts.length. When the caller supplies an empty array, the loop never executes, previewDeposit() returns the maximum uint256 value, and the nonzero-share check passes. receiveAssets() also iterates over the empty caller-supplied array, so it transfers no underlying tokens before _mint(receiver, shares) mints maximum shares for free.

      Claude Code harness (Opus 4.7)

      ERC4626MultiToken deposit accepts empty or partial asset arrays and mints unbacked shares

      src/2-audit/erc-4626/ERC4626MultiToken.sol:81 sev: critical

      Root cause

      deposit, previewDeposit, and convertToShares do not require the asset amount array length to equal the asset list length, and convertToShares uses the maximum uint256 value as a sentinel when no inputs are processed.

      Impact

      An attacker can mint unbacked shares for zero assets or for only a subset of required assets, corrupting share accounting and enabling reserve drains once redemption paths pay out correctly.

      Description

      ERC4626MultiToken accepts empty or partial asset amount arrays during deposit. Empty input returns the maximum uint256 share amount while transferring no assets; partial input mints shares using only the supplied assets while leaving the shares redeemable against the full basket.

      Pashov Skills

      ERC4626MultiToken sev: critical

      Description

      ERC4626MultiToken.convertToShares iterates user-supplied length, letting a depositor mint full-basket shares while depositing one asset. convertToShares (line 184) and receiveAssets (line 55) both loop over the user-controlled assetsAmounts.length; passing a length-1 array yields shares = X · totalWeights / weights[0] while only assets[0] is pulled, so the depositor mints full-basket shares while paying for a single asset and dilutes other LPs.

    • Multiple redeems of the same deposit are possible maia-4 · 4 writeups critical

      V12

      Failed deposits redeem repeatedly

      src/2-audit/ulysses-omnichain/BranchBridgeAgent.sol:237 sev: critical

      Root cause

      redeemDeposit() and _redeemDeposit() do not delete the deposit entry or change DepositStatus.Failed to a consumed state before or after refunding the recorded balances.

      Impact

      A user with any failed cross-chain deposit can repeatedly withdraw the recorded underlying and gas balances from the port and repeatedly mint the recorded hToken refund. This drains shared port liquidity and can inflate hToken supply far beyond the originally failed deposit.

      Description

      redeemDeposit() only checks that a deposit is marked Failed and then calls _redeemDeposit() without consuming the deposit entry or changing its status. _clearDeposit() marks the deposit as Failed when Anycall fallback reports the outbound message failed, and that status remains true after redemption. _redeemDeposit() then mints back hTokens through IPort.bridgeIn(), withdraws underlying tokens through IPort.withdraw(), and withdraws the stored wrapped gas amount to the recorded owner. Because neither the status nor the recorded amounts are cleared, the same failed nonce can be redeemed again and again for the same balances. BranchPort.withdraw() transfers underlying tokens and BranchPort.bridgeIn() mints branch hTokens when called by an active bridge agent, so repeated redemption is an actual value-transfer path rather than a harmless accounting duplicate.

      Claude Code harness (Opus 4.7)

      BranchBridgeAgent.redeemDeposit is callable by anyone and does not mark the deposit as redeemed — repeated calls mint unbounded hTokens

      src/2-audit/ulysses-omnichain/BranchBridgeAgent.sol:237 sev: critical

      Root cause

      _redeemDeposit does not mark the deposit as redeemed or delete it before payout, and redeemDeposit is externally callable by anyone.

      Impact

      A single failed deposit can be replayed to mint unbounded hTokens and repeatedly withdraw any available underlying or gas balance tied to the deposit.

      Description

      Failed branch deposits can be redeemed repeatedly because redeemDeposit only checks for Failed status and then pays out the recorded hTokens, underlying tokens, and deposited gas.

      Codex harness (GPT-5.5)

      Failed branch deposits can be redeemed repeatedly

      src/2-audit/ulysses-omnichain/BranchBridgeAgent.sol:237 sev: critical

      Root cause

      _redeemDeposit() pays out a failed deposit without deleting it or transitioning it to a terminal redeemed status.

      Impact

      The same nonce remains redeemable forever. The owner can repeatedly recover the same failed deposit; hToken portions are minted every time, and underlying portions and gas are transferred until branch port balances are exhausted.

      Description

      anyFallback() marks a deposit as DepositStatus.Failed, and redeemDeposit() allows redemption while that status remains failed. _redeemDeposit() returns/mints the recorded hTokens, underlying tokens, and deposited gas, but it never deletes the deposit or changes its status after payout.

      Pashov Skills

      BranchBridgeAgent sev: medium

      Description

      BranchBridgeAgent.redeemDeposit lacks an ownership check for the failed deposit being redeemed. Any third party can force-finalize a victim's Failed deposit, removing the user's ability to call retrySettlement; because the deposit status is not transitioned away from Failed, the same deposit may also remain redeemable until available balances are exhausted.

    • Broken fee sweep maia-5 · 4 writeups high

      V12

      Accumulated Fees Unsweepable

      src/2-audit/ulysses-omnichain/RootBridgeAgent.sol:786 sev: medium

      Root cause

      sweep clears accumulatedFees before using it and transfers ETH even though accumulated fee surplus is held as wrapped native token. The fee accounting and asset movement are inconsistent.

      Impact

      Protocol gas-fee surplus can become stuck in the bridge agent contract. This does not let an attacker steal the funds, but it prevents the intended DAO recovery of accumulated execution-fee balances.

      Description

      The bridge accounts excess execution gas as accumulatedFees, leaving the unspent wrapped-native balance in the contract after only minExecCost is unwrapped and deposited into Anycall. The sweep function then sets accumulatedFees to zero before transferring and uses the zeroed variable as the ETH transfer amount. It also never unwraps the accumulated WETH balance that represents those fees. As written, the DAO sweep path transfers zero and permanently loses its accounting pointer to the accrued wrapped-native fees.

      Claude Code harness (Opus 4.7)

      RootBridgeAgent.sweep zeroes the fee balance **before** transferring it (DAO can never collect fees)

      src/2-audit/ulysses-omnichain/RootBridgeAgent.sol:1111 sev: critical

      Root cause

      The fee balance is read after it has already been overwritten with zero.

      Impact

      DAO fee sweeps always transfer zero, leaving ETH trapped while erasing the accounting needed to recover the accrued amount.

      Description

      RootBridgeAgent.sweep clears accumulatedFees before using it as the ETH transfer amount.

      Codex harness (GPT-5.5)

      RootBridgeAgent sweep zeros accumulated fees before transferring

      src/2-audit/ulysses-omnichain/RootBridgeAgent.sol:1111 sev: low

      Root cause

      The function zeroes accumulatedFees before using it as the transfer amount.

      Impact

      Accumulated ETH fees remain stuck in the contract while accounting is reset.

      Description

      sweep() sets accumulatedFees = 0 and then transfers accumulatedFees ETH to the DAO. The transfer amount is always zero.

      Pashov Skills

      RootBridgeAgent sev: high

      Description

      RootBridgeAgent.sweep zeroes accumulatedFees before reading it for the transfer. Lines 1111-1115 set accumulatedFees = 0 and then pass accumulatedFees (now zero) to safeTransferETH(daoAddress, accumulatedFees); every sweep transfers 0 ETH while resetting the counter, so protocol fees are permanently stranded.

    • Asset removal is broken maia-6 · 4 writeups high

      V12

      Asset Removal Always Reverts

      src/2-audit/ulysses-amm/UlyssesToken.sol:57 sev: low

      Root cause

      The array-shift loop in removeAsset() uses i < assets.length while reading i + 1. It should stop at assets.length - 1 and update totalWeights before overwriting the removed weight.

      Impact

      The owner cannot remove a configured basket asset through the advertised management function. If a component must be deprecated, paused, or replaced, the token remains stuck with that asset until a new token is deployed or users migrate out through other means.

      Description

      removeAsset() is intended to remove an existing basket component when more than one asset remains. The shift loop starts at the removed index but continues while i < assets.length, and each iteration reads assets[i + 1] and weights[i + 1]. On the final iteration, i equals assets.length - 1, so the reads access index assets.length, which is out of bounds and reverts. This happens for removals at any valid index in any basket with more than one asset. The subsequent pop, balance update, and removed-asset transfer are therefore unreachable.

      Claude Code harness (Opus 4.7)

      UlyssesToken.removeAsset reverts via out-of-bounds, then (when fixed) sends every shareholder's residual balance to the owner

      src/2-audit/ulysses-amm/UlyssesToken.sol:57 sev: critical

      Root cause

      The removal loop reads past the end of the assets and weights arrays, subtracts the removed weight after shifting has overwritten it, fails to update shifted asset indexes, and transfers the removed asset balance to the owner.

      Impact

      Asset removal currently reverts. A naive fix would corrupt accounting and allow the owner to extract asset balances that back outstanding shareholder claims.

      Description

      UlyssesToken.removeAsset is unusable due to an out-of-bounds shift and would misallocate removed-asset value if partially fixed.

      Codex harness (GPT-5.5)

      UlyssesToken asset removal always reverts and can corrupt weights

      src/2-audit/ulysses-amm/UlyssesToken.sol:57 sev: medium

      Root cause

      The removal loop has an off-by-one out-of-bounds read and subtracts the removed weight after array elements have been shifted.

      Impact

      The owner cannot remove misconfigured or deprecated assets from a UlyssesToken, and accounting would be corrupted if the out-of-bounds bug were patched alone.

      Description

      removeAsset() shifts array elements with a loop bounded by i < assets.length, then reads assets[i + 1] and weights[i + 1]. On the final iteration it reads out of bounds and reverts. If the loop bound were fixed, totalWeights -= weights[assetIndex] is performed after shifting, so it subtracts the next asset's weight rather than the removed asset's original weight.

      Pashov Skills

      UlyssesToken sev: high

      Description

      UlyssesToken.removeAsset has an off-by-one shift and subtracts the wrong weight from totalWeights. Lines 64-67 loop for (i = assetIndex; i < assets.length; i++) assets[i] = assets[i+1]; weights[i] = weights[i+1]; and read past the end on the final iteration (revert), and line 69 totalWeights -= weights[assetIndex] runs after the shift, subtracting the wrong (already-overwritten) weight. assetId is also never re-indexed for surviving entries.

    • Unsupported function codes maia-7 · 2 writeups high

      V12

      System messages route to wrong handler

      src/2-audit/ulysses-omnichain/CoreBranchRouter.sol:46 sev: medium

      Root cause

      CoreBranchRouter sends request-type function ids through the system-response bridge path. The root router splits response ids and request ids across anyExecuteResponse and anyExecute, so 0x01 and 0x04 are dispatched to a handler that never recognizes them.

      Impact

      Global-token onboarding from a branch and branch-bridge-agent synchronization can be made non-functional through valid user calls. This blocks expansion and recovery/setup flows that depend on those router messages, leaving affected chains unable to register assets or bridge agents through the advertised entrypoints.

      Description

      addGlobalToken and syncBridgeAgent build payloads with function ids 0x01 and 0x04, but both submit them through performSystemCallOut. BranchBridgeAgent.performSystemCallOut wraps those payloads with bridge flag 0x00, and RootBridgeAgent.anyExecute routes flag 0x00 only into the root router's anyExecuteResponse handler. CoreRootRouter.anyExecuteResponse handles only 0x02 and 0x03, while the 0x01 and 0x04 token-management actions are implemented in the separate anyExecute handler. A valid branch request to add a global token or synchronize a bridge agent therefore reaches the wrong root-side dispatch table and returns unknown selector instead of performing the requested state transition.

      Pashov Skills

      CoreRootRouter sev: medium

      Description

      CoreRootRouter.anyExecuteResponse does not handle all function IDs emitted by the branch router. It only handles 0x02 and 0x03, while the matching CoreBranchRouter sends 0x01 and 0x04 as system request acknowledgements; those messages have no corresponding branch on the root side and therefore cannot be processed correctly.

    • Missing access control on anyExecuteNoSettlement maia-8 · 4 writeups high

      V12

      Inbound handler lacks authorization

      src/2-audit/ulysses-omnichain/BaseBranchRouter.sol:90 sev: high

      Root cause

      The override drops the inherited bridge-agent authorization at the cross-chain entrypoint. The downstream token-mapping acknowledgement path also trusts the router payload and writes mappings without binding the response to an authenticated pending root request.

      Impact

      An attacker can bypass the Anycall/root-bridge trust boundary for this router's no-settlement system messages. With bridge gas available from the router balance, the attacker can poison root token mappings for a chosen global token to an attacker-triggered branch hToken, causing subsequent bridge accounting for that asset and chain to use the wrong local token address and potentially strand bridged balances.

      Description

      CoreBranchRouter overrides BaseBranchRouter.anyExecuteNoSettlement without preserving the inherited requiresBridgeAgent gate. The base implementation restricts inbound execution to localBridgeAgentAddress, but the override is plain external, so any account can call the branch router's cross-chain message decoder directly. The reachable handler can create a branch hToken through the core-router-only hToken factory and then submit a system call back through the local bridge agent using calldata-controlled token identity fields. When the router has enough ETH to satisfy the bridge-agent fallback-gas requirement, an external caller can forge what should have been a root-origin add-global-token message and drive root-side setLocalToken mapping updates through the normal bridge path.

      Claude Code harness (Opus 4.7)

      CoreBranchRouter.anyExecuteNoSettlement is permissionless and contains duplicate-flag dead code

      src/2-audit/ulysses-omnichain/CoreBranchRouter.sol:149 sev: critical

      Root cause

      The override lacks bridge-agent authentication, and both branches check the same selector instead of using a distinct selector for factory registration.

      Impact

      An attacker can directly trigger token-registration handling, squat or corrupt local/global token mappings, and the intended bridge-agent-factory registration path cannot execute.

      Description

      CoreBranchRouter.anyExecuteNoSettlement exposes privileged system execution directly and has a duplicate selector branch that makes bridge-agent-factory handling unreachable.

      Codex harness (GPT-5.5)

      Public CoreBranchRouter execution can forge token-registration messages

      src/2-audit/ulysses-omnichain/BaseBranchRouter.sol:90 sev: high

      Root cause

      CoreBranchRouter overrides a bridge-agent callback without retaining the requiresBridgeAgent authentication modifier.

      Impact

      An attacker can cause unauthorized local-token mappings to be set for global tokens, corrupting token registries and potentially redirecting future bridge accounting to attacker-created branch hTokens.

      Description

      BaseBranchRouter.anyExecuteNoSettlement() is protected by requiresBridgeAgent, but CoreBranchRouter overrides it without the modifier. Any address can call CoreBranchRouter.anyExecuteNoSettlement() directly with _data[0] == 0x01. That path deploys a new local hToken and sends a system call through the legitimate branch bridge agent back to root.

      Pashov Skills

      CoreBranchRouter sev: critical

      Description

      CoreBranchRouter.anyExecuteNoSettlement is unauthenticated; anyone can deploy hTokens and corrupt the root token registry. Line 149-153 declares external virtual override with no caller check; payload (0x01, attackerAddr, …) reaches _receiveAddGlobalToken which deploys an attacker-controlled hToken and emits performSystemCallOut with funcId 0x03, instructing the root chain to overwrite RootPort.getLocalAddressFromGlobal[fromChain][attackerAddr].

    • The protocol fee from pools will be claimed to zero address maia-9 · 1 writeup high

      V12

      Factory Ownership Permanently Uninitialized

      src/2-audit/ulysses-amm/factories/UlyssesFactory.sol:37 sev: high

      Root cause

      UlyssesFactory inherits Ownable without initializing ownership. Downstream pool logic then treats factory.owner() as a live governance and fee-recipient address even though it is permanently zero.

      Impact

      Protocol fees accrued by factory-created pools can be permanently burned when the pool owner calls claimProtocolFees. The protocol also loses the ability to tune protocolFee on deployed pools, leaving a core economic parameter permanently fixed regardless of market or governance needs.

      Description

      UlyssesFactory inherits Solady Ownable but does not define a constructor or initializer that calls _initializeOwner. In Solady, _initializeOwner is the routine that writes the owner slot, while transferOwnership is guarded by onlyOwner; with a zero owner, no externally owned account can later transfer ownership. Pools created by the factory store the factory address and use factory.owner() as the protocol-fee recipient and as the only address allowed to change protocolFee. As a result, every pool deployed through this factory has an unusable protocol-fee admin path, and any claimed protocol fees are transferred to address(0).

    • Unlimited cross-chain asset transfer without deposit requirement maia-10 · 3 writeups high

      V12

      Retryable Settlement Replay

      src/2-audit/ulysses-omnichain/RootBridgeAgent.sol:601 sev: critical

      Root cause

      _clearSettlement mutates a memory Settlement and fails to persist the status transition to storage before resending the settlement message. The retry path is therefore non-idempotent.

      Impact

      A single failed-then-retried settlement can be replayed into multiple successful branch payouts. This enables repeated withdrawals or hToken mints from the branch side for one root-side settlement, directly draining port reserves or creating unbacked hTokens.

      Description

      _clearSettlement loads the settlement into memory, checks that the stored status is Pending, and then changes only the memory copy to Success. The function writes the modified callData back to storage but never writes the updated status, so the settlement remains Pending after a retry is submitted. Because branch-side settlement execution clears tokens from the encoded message and does not consume the root settlement nonce, the same pending settlement can be retried repeatedly. After any pending settlement succeeds once, an attacker can call clearSettlement again to resend the same payout message and release or mint the same branch assets multiple times.

      Codex harness (GPT-5.5)

      Failed root settlements can be replayed after a successful retry

      src/2-audit/ulysses-omnichain/RootBridgeAgent.sol:601 sev: critical

      Root cause

      Settlement status is updated only on a memory copy rather than the storage settlement record, and the branch agent has no replay protection for settlement nonces.

      Impact

      A user can repeatedly call clearSettlement() for the same nonce, sending the same settlement payload to the branch. The branch will mint hTokens or withdraw underlying each time, causing unbacked hToken inflation and/or draining destination branch port liquidity.

      Description

      _clearSettlement() copies the settlement from storage into memory, requires that the memory copy is Pending, and then sets only the memory copy to Success. The storage status is never updated; only callData is written back. Therefore a pending settlement remains pending even after _performCall() successfully clears it on the destination branch.

      Pashov Skills

      RootBridgeAgent sev: medium

      Description

      RootBridgeAgent._clearSettlement updates the settlement status only on a memory copy. The function loads Settlement memory settlement = ..., sets settlement.status = Success, and writes back only getSettlement[...].callData; the storage status remains Pending, allowing unbounded clearSettlement retries and repeated evaluation of userFeeInfo.

    • ChainId type confusion maia-11 high
    • Incorrect accounting of total and strategies debt maia-12 · 4 writeups medium

      V12

      Debt Forgiven Without Repayment

      src/2-audit/ulysses-omnichain/BranchPort.sol:120 sev: high

      Root cause

      replenishReserves uses the caller-supplied _amount for debt reduction instead of the actual repayment amount requested from IPortStrategy.withdraw. It also charges debt to msg.sender while the strategy being withdrawn from is supplied separately as _strategy, creating inconsistent repayment accounting.

      Impact

      An approved or compromised port strategy can withdraw excess reserves through manage, then erase the recorded debt without sending tokens back whenever the port is not below its reserve requirement. The port permanently loses those tokens while its accounting reports that the strategy debt has been repaid, weakening later reserve calculations and bridge liquidity availability.

      Description

      replenishReserves calls the selected strategy for only min(_amount, reservesLacking) tokens, but it always decreases accounting debt by the full caller-supplied _amount. When the port is already above its minimum reserve ratio, _reservesLacking returns zero, so an approved strategy can call replenishReserves with its outstanding debt and make the port request a zero-token withdrawal. The same call then subtracts the full _amount from getPortStrategyTokenDebt[msg.sender][_token] and getStrategyTokenDebt[_token], clearing debt even though no reserves were returned. Because manage transfers excess reserves to the strategy and records that amount as debt, this interaction lets an approved strategy convert managed reserves into untracked losses. The external call and subsequent accounting use different repayment amounts, so the debt invariant no longer matches actual token balances.

      Claude Code harness (Opus 4.7)

      BranchPort.replenishReserves debits the wrong account and accepts an _amount larger than what is actually pulled from the strategy

      src/2-audit/ulysses-omnichain/BranchPort.sol:152 sev: high

      Root cause

      Debt accounting uses the caller and requested amount rather than the replenished strategy and actual withdrawn amount.

      Impact

      Strategy debt can be reduced without equivalent repayment, causing per-strategy and global debt accounting to diverge from real reserves.

      Description

      BranchPort.replenishReserves caps the actual strategy withdrawal but subtracts the uncapped requested amount from debt, and it updates debt for msg.sender instead of the strategy.

      Codex harness (GPT-5.5)

      Port strategy debt can be erased without repaying the withdrawn amount

      src/2-audit/ulysses-omnichain/BranchPort.sol:153 sev: high

      Root cause

      Debt accounting uses the requested _amount rather than the actual withdrawn amount and indexes per-strategy debt by msg.sender instead of _strategy.

      Impact

      A whitelisted strategy can return only a small reserve shortfall while reducing debt by the full amount, then borrow again against understated debt, draining reserves over time while the port believes prior debt was repaid.

      Description

      replenishReserves() withdraws only min(_amount, reservesLacking) from the strategy, but it always decreases debt by _amount. It also subtracts from getPortStrategyTokenDebt[msg.sender][_token] instead of getPortStrategyTokenDebt[_strategy][_token].

      Pashov Skills

      BranchPort sev: high

      Description

      BranchPort.replenishReserves is unauthenticated and keys debt off msg.sender, breaking strategy accounting. Line 153 has no caller modifier; the function pulls from _strategy via IPortStrategy(_strategy).withdraw(...) but decrements getPortStrategyTokenDebt[msg.sender][_token] -= _amount at line 161. Any non-strategy caller underflow-reverts after the strategy already executed withdraw, and even the legitimate strategy can deduct the uncapped _amount while only min(_amount, reservesLacking) was returned.

    • Bridging assets erroneously mints new assets maia-13 medium
  • Metavest Solidity On-chain vesting + token-grant tooling. Tasks 12 V12 12/12 Claude 11/12 Codex 9/12 Pashov 8/12
    Read the audit report
    • Anyone can vote on any majority amendment proposal with arbitrary voting power metavest-1 · 4 writeups high

      V12

      Removed milestones strand funded awards

      src/MetaVesTController.sol:319 sev: high

      Root cause

      BaseAllocation.removeMilestone() deletes the milestone record without returning or otherwise accounting for the ERC20 tokens that were pre-funded for that milestone.

      Impact

      Funded milestone tokens can become inaccessible to both the grantee and the authority after a legitimate milestone-removal flow. The loss is permanent for allocation types whose recovery paths rely on stored milestone amounts rather than sweeping excess token balance.

      Description

      Milestone awards are funded into the allocation when the grant is created or when a new milestone is added later. BaseAllocation.removeMilestone() only deletes the milestone struct and emits an event; it does not transfer the corresponding milestoneAward back to the authority or preserve any accounting path by which the grantee can later earn it. After deletion, withdrawal calculations cannot include that award because confirmMilestone() can no longer add the deleted award into milestoneAwardTotal or milestoneUnlockedTotal. Termination recovery also sums the current milestone array, so the deleted award is omitted from the authority recovery calculation. The result is that removing an uncompleted funded milestone can leave those ERC20 tokens permanently stuck in the allocation contract.

      Claude Code harness (Opus 4.7)

      voteOnMetavestAmendment does not verify that _grant belongs to the proposal's set

      src/MetaVesTController.sol:589 sev: high

      Root cause

      voteOnMetavestAmendment never checks _grant membership in sets[_setName].

      Impact

      An outsider with high governing power can pass amendments for unrelated sets.

      Description

      Votes can be cast using a grant outside the proposal's set, adding unrelated governing power to the tally.

      Codex harness (GPT-5.5)

      Majority amendment votes can be forged with arbitrary or unfunded allocation contracts

      sev: high

      Root cause

      Vote counting trusts any allocation-like contract whose grantee() is msg.sender and uses nominal governing power without verifying set membership, controller provenance, or funding.

      Impact

      An attacker can add fake nominal governing power and pass majority thresholds, allowing authority to execute amendments that real set members did not approve.

      Description

      The authority's majority-consent constraint can be bypassed using allocation contracts that are not members of the affected set and were not created or funded by the controller. voteOnMetavestAmendment() does not verify that _grant is in _setName, that _grant was created by this controller, or that the allocation is backed by any tokens. The allocation factories and allocation constructors can create allocations with arbitrary accounting parameters. getGoverningPower() is derived from stored allocation fields and elapsed time, not from the allocation's token balance.

      Pashov Skills

      metavestController sev: medium

      Description

      voteOnMetavestAmendment does not verify that _grant is a member of _setName. The function authenticates the voter against _grant's grantee and reads _callerPower from _grant, but never checks that _grant is actually a member of sets[_setName]. A grantee with a grant in any set can use that grant's power to vote on a different set's proposal, pushing currentVotingPower above the totalVotingPower (which was summed only over the targeted set's members at proposal time).

    • Possibility for users to buy tokens from MetaVesT for free metavest-2 · 4 writeups high

      V12

      Unbound Votes Pass Majorities

      src/MetaVesTController.sol:140 sev: high

      Root cause

      voteOnMetavestAmendment fails to bind the voter _grant to the proposal set or proposal-time voting-power snapshot before crediting its governing power.

      Impact

      A malicious or compromised authority can manufacture majority consent with voting power outside the intended voter set, then change vesting rates, unlock rates, prices, transferability, stop times, or milestones without approval from the affected majority. This breaks the set-level consent boundary and enables unauthorized state changes to financial allocations.

      Description

      The majority-amendment proposal snapshots totalVotingPower by iterating only sets[setName], so the denominator is meant to represent that set. The voting function then accepts a caller-supplied _grant, checks only that BaseAllocation(_grant).grantee() equals msg.sender, and adds that _grant's getGoverningPower() to the proposal. It never verifies that _grant is a member of _setName, was a member when the proposal was created, or was deployed by this controller. A non-member allocation, a later-added allocation, or an allocation-like contract controlled by the voter can therefore contribute voting power that was not included in totalVotingPower. Once the inflated currentVotingPower satisfies the ratio in consentCheck, the authority can execute consent-gated parameter updates for grants in the target set.

      Claude Code harness (Opus 4.7)

      getPaymentAmount divides twice when paymentDecimals < exerciseTokenDecimals, drastically underpricing exercise/repurchase

      src/TokenOptionAllocation.sol; src/RestrictedTokenAllocation.sol:116 sev: critical

      Root cause

      The else branch performs the same division as the >= branch and then applies an additional division.

      Impact

      Common 18-decimal vesting token / 6-decimal USDC configurations can compute zero payment, allowing free option exercise or free restricted-token repurchase.

      Description

      When payment token decimals are fewer than exercise token decimals, the code divides by exercise decimals and then divides again by the decimal difference.

      Codex harness (GPT-5.5)

      Payment calculation undercharges to zero or near-zero when payment token has fewer decimals

      sev: high

      Root cause

      getPaymentAmount() divides by vesting token decimals and then divides again by the decimal difference when paymentDecimals < vestingTokenDecimals.

      Impact

      Token options can be exercised for free or near-free, and authority can repurchase restricted tokens without paying or underpaying the grantee.

      Description

      Token options can be exercised for free, and restricted token repurchases can be made without paying the grantee, when the payment token has fewer decimals than the vesting token. The first division already converts _amount from vesting-token base units into whole-token price units denominated in the payment token's decimals. The second division undercharges by an additional decimal factor.

      Pashov Skills

      TokenOptionAllocation sev: critical

      Description

      TokenOptionAllocation.getPaymentAmount divides twice when paymentDecimals < exerciseTokenDecimals, allowing free exercise. The else branch divides by 10**exerciseTokenDecimals and then again by 10**(exerciseTokenDecimals - paymentDecimals), collapsing payment to zero whenever the payment token has fewer decimals than the exercise token (e.g., USDC paying for an 18-decimal grant), letting the grantee exercise options for ~0 paymentToken.

    • Incorrect calculation in terminate function metavest-3 · 4 writeups high

      V12

      Payment Decimals Are Double Divided

      src/RestrictedTokenAllocation.sol:116 sev: critical

      Root cause

      The decimal-normalization branch for lower-decimal payment tokens applies an extra scale-down after already dividing by the allocation token's decimals.

      Impact

      For common 18-decimal restricted tokens paid with 6-decimal tokens, the payment is reduced by 1e12. The authority can repurchase grantee tokens for a tiny fraction of the configured repurchase price, causing direct underpayment to the grantee when claimRepurchasedTokens() is called.

      Description

      getPaymentAmount() is intended to return a repurchase payment in payment-token decimals, but when paymentToken.decimals() is lower than the allocation token's decimals it divides twice by token-decimal factors. The first division by 10**repurchaseTokenDecimals already converts _amount * repurchasePrice into payment-token units when repurchasePrice is denominated in payment-token decimals per whole restricted token. The else branch then divides again by 10**(repurchaseTokenDecimals - paymentDecimals), underpricing every repurchase where the restricted token has more decimals than the payment token. repurchaseTokens() transfers only this understated amount from the authority to the award before sending the full allocation-token amount to the authority.

      Claude Code harness (Opus 4.7)

      terminate() accounting double-counts tokensWithdrawn, causing permanent loss of vested tokens for grantee

      src/VestingAllocation.sol; src/TokenOptionAllocation.sol:79 sev: critical

      Root cause

      Recovery formula adds tokensWithdrawn even though withdrawn tokens already left the contract balance.

      Impact

      Grantees can permanently lose vested tokens to authority; if tokensWithdrawn exceeds the unvested portion, safeTransfer reverts and terminate becomes DoS'd.

      Description

      terminate() calculates recovery with an extra + tokensWithdrawn, over-transferring to authority by exactly tokensWithdrawn and potentially DoSing termination.

      Codex harness (GPT-5.5)

      Termination over-recovers previously withdrawn tokens and can seize vested or exercised balances

      sev: high

      Root cause

      Termination recovery formulas add tokensWithdrawn even though withdrawn tokens have already left the allocation contract.

      Impact

      The authority can receive the grantee's remaining vested, unlocked, or exercised tokens after termination, or termination can revert and leave the allocation unterminable.

      Description

      A grantee can lose vested, unlocked, or already exercised tokens when the authority terminates after any prior withdrawal. tokensWithdrawn should not increase the authority's recovery. Withdrawn tokens have already left the allocation contract, so the amount that must remain in the contract for the grantee is vested - withdrawn, and the amount recoverable by authority is simply total - vested. Adding tokensWithdrawn recovers too much from the remaining balance. For token options, the same issue can seize already exercised but not-yet-withdrawn tokens. If the grantee has already withdrawn all vested tokens, the same formula can instead make termination revert because tokensToRecover exceeds the contract balance, leaving the allocation unterminable.

      Pashov Skills

      VestingAllocation sev: critical

      Description

      VestingAllocation.terminate over-counts tokensWithdrawn, stripping grantee or bricking termination. tokensToRecover = tokenStreamTotal + milestonesAllocation - getVestedTokenAmount() + tokensWithdrawn adds tokens that have already left the contract, so termination either reverts (when 2·tokensWithdrawn > vested) or transfers more than the unvested remainder — taking tokensWithdrawn worth of the grantee's vested-but-unwithdrawn balance.

    • Reward tokens corresponding to removed milestones are locked metavest-4 · 4 writeups critical

      V12

      Withdrawals Break Termination Recovery

      src/BaseAllocation.sol:270 sev: high

      Root cause

      terminate() double-counts previously withdrawn option tokens by adding tokensWithdrawn to the recovery amount instead of basing recovery on the remaining contract balance and outstanding grantee entitlement.

      Impact

      A grantee can withdraw vested unlocked options and then block authority termination and recovery of unvested options by making terminate() over-transfer from an insufficient balance. This lets vesting continue after the intended stop and can convert otherwise recoverable unvested options into exercisable tokens over time.

      Description

      BaseAllocation.withdraw() increments tokensWithdrawn after allocation tokens leave the contract. TokenOptionAllocation.terminate() later calculates tokensToRecover as the total funded allocation minus exercisable and exercised amounts, but then adds tokensWithdrawn back into the amount to transfer to the authority. Those withdrawn tokens are no longer held by the allocation, so the termination path attempts to recover tokens that already left the contract. When a grantee has exercised and withdrawn currently vested/unlocked options, the computed recovery can exceed the contract balance and safeTransfer() reverts before terminated is set. If the transfer does not revert, the same over-recovery leaves too few tokens backing already exercised but not-yet-withdrawn balances.

      Claude Code harness (Opus 4.7)

      Removed milestones become permanently orphaned tokens

      src/BaseAllocation.sol:243 sev: high

      Root cause

      removeMilestone deletes the milestone struct without transferring or tracking its milestoneAward.

      Impact

      Removed milestone tokens are skipped by withdrawal and termination accounting and become permanently stranded.

      Description

      Removing a milestone deletes accounting but does not return the pre-funded milestone tokens or allow the grantee to claim them.

      Codex harness (GPT-5.5)

      Removing a milestone strands the funded milestone tokens in the allocation

      sev: medium

      Root cause

      Removing a milestone deletes its accounting record without transferring or separately tracking the funded milestoneAward.

      Impact

      Removed milestone tokens become stuck in the allocation, unavailable to both grantee and authority.

      Description

      Tokens funded for a removed milestone become neither withdrawable by the grantee nor recoverable by the authority. Allocations are funded with tokenStreamTotal + milestoneTotal, but removeMilestone() deletes the milestone struct without transferring the award out or recording it as recoverable. Termination only sums current milestones, so a deleted milestone's funded tokens are excluded from both grantee vesting/unlocking totals and authority recovery calculations.

      Pashov Skills

      BaseAllocation sev: high

      Description

      removeMilestone strands prefunded milestoneAward tokens permanently. delete milestones[idx] zeroes the struct in place without returning the milestone's prefunded tokens (transferred from authority at createMetavest) to anyone. The deleted slot contributes 0 to subsequent terminate recovery accounting, and confirmMilestone on the slot is a no-op, leaving the tokens trapped in the allocation contract forever.

    • Unable to unlock milestone metavest-5 · 1 writeup high

      V12

      Completed Milestones Never Unlock

      src/BaseAllocation.sol:139 sev: high

      Root cause

      Milestone completion has no later unlock path for awards with unlockOnCompletion == false; the linear unlock schedule excludes milestone awards and only milestoneUnlockedTotal can unlock them.

      Impact

      Any milestone award configured not to unlock immediately can be permanently stuck in the allocation after its conditions are satisfied. The grantee cannot withdraw the completed milestone award, and the authority cannot safely rely on normal vesting/unlocking progression to release it later.

      Description

      confirmMilestone() always adds a completed milestone award to milestoneAwardTotal, making it part of the vested side of VestingAllocation.getVestedTokenAmount(). It only adds the same award to milestoneUnlockedTotal when milestone.unlockOnCompletion is true. VestingAllocation.getUnlockedTokenAmount() caps linear unlocking at allocation.tokenStreamTotal and then adds only milestoneUnlockedTotal, while the allocation struct defines tokenStreamTotal as excluding each milestone award. A completed milestone configured with unlockOnCompletion == false therefore becomes vested but is never included in unlocked accounting, so getAmountWithdrawable() permanently excludes it from the grantee’s withdrawable balance.

    • The grantee cannot revoke consent to the amendment metavest-6 · 4 writeups medium

      V12

      Calldata hash omits grant

      src/MetaVesTController.sol:140 sev: high

      Root cause

      proposeMajorityMetavestAmendment and the majority branch of consentCheck hash only the trailing calldata word, omitting the _grant argument that determines which allocation is amended.

      Impact

      The authority can redirect a valid majority approval from one allocation to another allocation in the same set. This lets unapproved grantees have their rates, transferability, prices, stop times, milestone state, or governance mode changed whenever the trailing argument matches the approved proposal.

      Description

      The majority proposal hash commits only to the last 32 bytes of calldata. proposeMajorityMetavestAmendment stores keccak256(_callData[_callData.length - 32:]), and the majority branch of consentCheck compares the same trailing word from the actual execution calldata. For the consent-gated update functions, the final calldata word is the new value, index, or enum, while the affected _grant address is encoded in an earlier word. A majority vote that appears to approve changing one grant to a value therefore authorizes the authority to apply that same value to any other grant in the same set. The affected grant is not cryptographically bound to the proposal that voters saw.

      Claude Code harness (Opus 4.7)

      consentToMetavestAmendment ignores _inFavor argument — grantee can never withhold or revoke consent

      src/MetaVesTController.sol:185 sev: critical

      Root cause

      Implementation ignores _inFavor and always sets inFavor = true.

      Impact

      Any grantee call to consentToMetavestAmendment — including a deliberate false to revoke — silently grants permanent consent. Authority can then execute the pending amendment for the lifetime of the contract. A grantee cannot undo an accidental click or change their mind after authority's other off-chain commitments fail.

      Description

      The consent function hard-codes approval to true regardless of the _inFavor parameter.

      Codex harness (GPT-5.5)

      Individual grantee consent cannot be rejected or revoked, and individual proposals never expire

      sev: medium

      Root cause

      The contract ignores _inFavor and always stores approval, and individual proposals lack timestamps/expiry checks.

      Impact

      Authority can execute amendments that grantees attempted to reject, and can execute stale individual approvals after the documented one-week window.

      Description

      A grantee who attempts to reject an amendment accidentally approves it, and old approvals remain executable forever. consentToMetavestAmendment() accepts _inFavor but always stores inFavor = true. The emitted event uses _inFavor, so off-chain monitoring can show a rejection while on-chain state stores approval. Individual amendment proposals have no timestamp and the one-week AMENDMENT_TIME_LIMIT is not applied to them.

      Pashov Skills

      metavestController sev: critical

      Description

      consentToMetavestAmendment ignores _inFavor, making revocation impossible. The function unconditionally writes inFavor = true regardless of the supplied _inFavor argument. The natspec explicitly markets this parameter as the grantee's revocation mechanism, but a grantee calling with _inFavor=false still leaves the consent intact, letting authority execute the amendment after the grantee tried to withdraw approval.

    • The authority can execute majority-consented proposals with arbitrary data metavest-7 · 4 writeups high

      V12

      Completed milestone removal corrupts accounting

      src/BaseAllocation.sol:216 sev: high

      Root cause

      BaseAllocation.removeMilestone deletes completed milestones without reversing the cumulative milestone accounting created by confirmMilestone.

      Impact

      A completed milestone can be removed into a state where later termination or restricted-token repurchase reverts or computes against inconsistent milestone totals. This can freeze post-termination recovery and leave the allocation unable to reconcile its funded milestone tokens correctly.

      Description

      BaseAllocation.confirmMilestone marks a milestone complete and increments the cumulative milestoneAwardTotal, and optionally milestoneUnlockedTotal, that derived contracts later use for vesting, unlocking, and repurchase calculations. BaseAllocation.removeMilestone is named and documented by the controller as a path for uncompleted milestones, but the implementation only checks that the index exists and then deletes the milestone. It never rejects milestone.complete == true and never decrements the cumulative milestone totals that were already increased at confirmation time. After a completed milestone is deleted, the milestone array no longer contains its award while the cumulative entitlement variables still do. Derived termination and repurchase calculations combine both sources, so they can underflow or miscompute recovery/repurchase amounts after the invalid removal.

      Claude Code harness (Opus 4.7)

      Majority proposal hash only covers the *last 32 bytes* of calldata — any grant in the set can be substituted

      src/MetaVesTController.sol:580 sev: high

      Root cause

      proposeMajorityMetavestAmendment and consentCheck hash _callData[_callData.length - 32:] / _data[_data.length - 32:].

      Impact

      An approved amendment can be applied to a different grant in the same set or to different parameters that share the last 32 bytes.

      Description

      Majority proposal commitments hash only the trailing calldata word, not the selector, grant, or intermediate parameters.

      Codex harness (GPT-5.5)

      Majority amendment approvals are not bound to full calldata, do not expire on execution, and are not consumed

      sev: high

      Root cause

      Majority amendment proposal hashing and validation only use the final calldata word; execution does not enforce expiry; _resetAmendmentParams() does not clear functionToSetMajorityProposal.

      Impact

      Authority can reuse a majority approval for other grants or matching calls in the same set indefinitely, including after the intended one-week window.

      Description

      A majority approval for one call can be reused for other grants and can remain executable forever. The proposal hash and consent check bind only the last 32 bytes of calldata and not the target grant address or full parameter set. Execution never checks amendment expiry, and majority proposals are never cleared or marked consumed.

      Pashov Skills

      metavestController sev: high

      Description

      Majority-amendment data hash covers only the trailing 32 bytes of calldata, allowing cross-grantee replay within a set. Both proposal storage (keccak256(_callData[length-32:])) and verification (keccak256(_data[length-32:])) hash only the last 32 bytes of calldata. For functions like updateMetavestUnlockRate(address _grant, uint160 _rate) the _grant argument is excluded from the hash, letting authority retarget an amendment approved for one set member at any other set member with the same trailing value.

    • Removing confirmed milestones is possible metavest-8 · 3 writeups high

      V12

      Majority Approvals Never Expire

      src/MetaVesTController.sol:27 sev: high

      Root cause

      Majority proposal lifecycle handling omits expiry enforcement at execution, never clears majority proposal state, and uses an inverted timestamp check when deciding whether a proposal is already pending.

      Impact

      A malicious or compromised authority can wait until circumstances change and execute old majority approvals long after voters' intended one-week authorization window. If a proposal is stale or erroneous, the same bug can also prevent the set from creating a fresh replacement proposal for that function.

      Description

      The controller defines AMENDMENT_TIME_LIMIT as a one-week limit for amendments, and voting enforces that limit through _checkFunctionToTokenToAmendmentTime. The execution-side consentCheck for majority proposals never checks proposal.time + AMENDMENT_TIME_LIMIT; it only checks pending status, the voting-power ratio, and the calldata suffix hash. Successful update functions call _resetAmendmentParams, but that helper deletes only the individual grantee proposal and never clears functionToSetMajorityProposal. The re-proposal guard is also inverted: it reverts when isPending && block.timestamp > proposal.time, which becomes true after the proposal's creation block rather than only during the unexpired window. As a result, a passed majority approval remains executable indefinitely, while stale pending proposals can permanently block replacement proposals for the same selector and set.

      Claude Code harness (Opus 4.7)

      terminate of a milestone whose status is already complete corrupts accounting

      src/BaseAllocation.sol:243 sev: medium

      Root cause

      removeMilestone does not reject completed milestones or decrement milestoneAwardTotal / milestoneUnlockedTotal.

      Impact

      Termination accounting can underflow or strand tokens because totals and milestone array sums diverge.

      Description

      Removing completed milestones zeroes the milestone entry but leaves cumulative totals credited.

      Codex harness (GPT-5.5)

      Removing a milestone strands the funded milestone tokens in the allocation

      sev: medium

      Root cause

      Removing a milestone deletes its accounting record without transferring or separately tracking the funded milestoneAward.

      Impact

      Removed milestone tokens become stuck in the allocation, unavailable to both grantee and authority.

      Description

      Tokens funded for a removed milestone become neither withdrawable by the grantee nor recoverable by the authority. Allocations are funded with tokenStreamTotal + milestoneTotal, but removeMilestone() deletes the milestone struct without transferring the award out or recording it as recoverable. Termination only sums current milestones, so a deleted milestone's funded tokens are excluded from both grantee vesting/unlocking totals and authority recovery calculations.

    • The function proposeMajorityMetavestAmendment cannot identify expired proposals metavest-9 · 3 writeups high

      V12

      Revocations Become Approvals

      src/MetaVesTController.sol:140 sev: high

      Root cause

      consentToMetavestAmendment does not persist the _inFavor argument and instead always records approval.

      Impact

      A grantee cannot safely revoke or reject a pending individual amendment through the provided interface. If the grantee submits _inFavor = false, the authority receives valid on-chain consent and can perform updates the grantee explicitly attempted to deny.

      Description

      The individual amendment consent function accepts an _inFavor boolean that is documented as allowing the grantee to revoke a prior decision if the authority delays or the agreement changes. The implementation ignores that boolean and unconditionally writes functionToGranteeToAmendmentPending[_msgSig][_grant].inFavor = true. The event still emits the user-supplied _inFavor value, so off-chain observers can see a rejection while on-chain state records approval. The individual branch of consentCheck later relies directly on proposal.inFavor, so a grantee attempting to reject or revoke a pending amendment actually enables it. The authority can then execute the matching consent-gated update against the grant.

      Claude Code harness (Opus 4.7)

      Backwards condition in proposeMajorityMetavestAmendment allows reusing an unexpired-pending slot

      src/MetaVesTController.sol:568 sev: medium

      Root cause

      Condition uses block.timestamp > proposal.time instead of checking against proposal.time + AMENDMENT_TIME_LIMIT for unexpired proposals.

      Impact

      Combined with H-02, the first majority proposal for a (msgSig, set) is also the last.

      Description

      The pending-proposal check compares against the proposal time incorrectly and reverts after time has passed rather than while unexpired.

      Pashov Skills

      metavestController sev: high

      Description

      proposeMajorityMetavestAmendment time check is inverted, permanently locking a (sig, set) pair after the first proposal. The guard if (isPending && block.timestamp > proposal.time) revert triggers in every block strictly after creation, and the majority proposal is never reset elsewhere, so once any majority proposal is made for a (msgSig, setName) pair no subsequent proposal for that pair can ever be submitted again.

    • Incorrect calculation about voting power metavest-10 · 3 writeups medium

      V12

      Unbound Votes Pass Majorities

      src/MetaVesTController.sol:140 sev: high

      Root cause

      voteOnMetavestAmendment fails to bind the voter _grant to the proposal set or proposal-time voting-power snapshot before crediting its governing power.

      Impact

      A malicious or compromised authority can manufacture majority consent with voting power outside the intended voter set, then change vesting rates, unlock rates, prices, transferability, stop times, or milestones without approval from the affected majority. This breaks the set-level consent boundary and enables unauthorized state changes to financial allocations.

      Description

      The majority-amendment proposal snapshots totalVotingPower by iterating only sets[setName], so the denominator is meant to represent that set. The voting function then accepts a caller-supplied _grant, checks only that BaseAllocation(_grant).grantee() equals msg.sender, and adds that _grant's getGoverningPower() to the proposal. It never verifies that _grant is a member of _setName, was a member when the proposal was created, or was deployed by this controller. A non-member allocation, a later-added allocation, or an allocation-like contract controlled by the voter can therefore contribute voting power that was not included in totalVotingPower. Once the inflated currentVotingPower satisfies the ratio in consentCheck, the authority can execute consent-gated parameter updates for grants in the target set.

      Claude Code harness (Opus 4.7)

      Majority proposal voting-power snapshot vs live-power vote tally allows passage with sub-majority

      src/MetaVesTController.sol:571 sev: medium

      Root cause

      Inconsistent use of snapshot denominator and live numerator in majority vote tally.

      Impact

      Amendments can pass with a sub-majority relative to the original set, or voters can lose influence by withdrawing before voting.

      Description

      Proposal total power is snapshotted, but individual votes use live governing power that may grow or shrink.

      Pashov Skills

      metavestController sev: medium

      Description

      Majority amendment voting compares a snapshotted total against live per-voter power. totalVotingPower is recorded once when the proposal is created across the set members, but each voter contribution is later read live through getGoverningPower(). Because confirmMilestone can be called between proposal creation and voting, a voter's contribution can increase above the amount included in the denominator and incorrectly swing majority outcomes.

    • RestrictedTokenAllocation lacks repurchase deadline check metavest-11 · 3 writeups medium

      V12

      Repurchase Deadline Is Unenforced

      src/RestrictedTokenAllocation.sol:98 sev: high

      Root cause

      repurchaseTokens() omits the deadline check for the shortStopDate set during termination, so the short-stop window is never enforced.

      Impact

      The authority can repurchase unvested restricted tokens long after the intended deadline. Grantees lose the finality that should occur when the short-stop period expires, and tokens expected to remain in the award after that period can still be taken by the authority for the repurchase price.

      Description

      terminate() records shortStopDate = block.timestamp + shortStopDuration for the restricted-token repurchase window, but repurchaseTokens() never checks either shortStopDate or block.timestamp. The authority can therefore call repurchaseTokens() at any time after termination, even after the contractual short-stop window has expired. This contradicts the file's own state model where shortStopDuration and shortStopDate exist specifically to bound post-termination restricted-token repurchases. The omission turns an intended time-limited repurchase right into an indefinite one.

      Claude Code harness (Opus 4.7)

      RestrictedTokenAward.shortStopDate is never enforced; repurchase window is effectively infinite

      src/RestrictedTokenAllocation.sol:134 sev: high

      Root cause

      repurchaseTokens does not reference shortStopDate.

      Impact

      Authority can repurchase unvested restricted tokens indefinitely after termination, contrary to the intended limited duration.

      Description

      The restricted token repurchase deadline is stored but never checked during repurchase.

      Codex harness (GPT-5.5)

      Restricted-token repurchase deadline is never enforced

      sev: medium

      Root cause

      repurchaseTokens() does not enforce the shortStopDate set during termination.

      Impact

      Authority can repurchase tokens months or later after the intended repurchase window has expired at the stale repurchase price.

      Description

      Authority can exercise the restricted-token repurchase right indefinitely after termination, even though the contract records a short-stop deadline. repurchaseTokens() never checks block.timestamp <= shortStopDate.

    • Accumulation of vested or unlocked tokens metavest-12 · 3 writeups medium

      V12

      Rate changes rewrite accrual history

      src/BaseAllocation.sol:193 sev: high

      Root cause

      Mutable rate updates in BaseAllocation do not checkpoint historical accrual before changing allocation.vestingRate or allocation.unlockRate. The derived getters use the current rate over the full elapsed period from the original start timestamp.

      Impact

      A grantee can lose previously accrued entitlement after a later schedule amendment, or receive tokens earlier than intended after a rate increase. The bug corrupts the core vesting/unlocking invariant across vesting allocations, token options, and restricted token awards.

      Description

      BaseAllocation.updateVestingRate and BaseAllocation.updateUnlockRate overwrite the live rate fields without first checkpointing already accrued vested or unlocked amounts and without resetting the corresponding start time. The concrete allocation implementations then compute entitlement as total elapsed time from the original start timestamp multiplied by the current allocation.vestingRate or allocation.unlockRate. After any controller-approved rate change, historical time is therefore recalculated at the new rate instead of preserving the amount that had accrued under the old rate. A rate decrease can erase already vested or unlocked-but-unwithdrawn tokens, while a rate increase can make tokens immediately withdrawable or exercisable as if the higher rate had applied since inception. BaseAllocation.withdraw trusts the derived getAmountWithdrawable() value, so the corrupted cumulative accounting directly controls token withdrawals.

      Claude Code harness (Opus 4.7)

      Retroactive unlock/vesting rate changes can underflow getAmountWithdrawable and freeze grantee

      src/VestingAllocation.sol; src/RestrictedTokenAllocation.sol:95 sev: high

      Root cause

      getUnlockedTokenAmount and analogous vesting math recalculate from current rate from scratch instead of snapshotting accrued amounts at rate changes.

      Impact

      getAmountWithdrawable can underflow and revert, freezing grantee withdrawals despite prior legitimate eligibility.

      Description

      Rate updates retroactively recalculate all history; lowering rates can make accrued unlocked/vested amounts less than already withdrawn tokens.

      Codex harness (GPT-5.5)

      Updating vesting or unlock rates retroactively rewrites all accrued vesting/unlocking

      sev: medium

      Root cause

      Rate update functions only replace rate fields, while vesting/unlocking calculations recompute from original start times using current rates without checkpointing accrued amounts.

      Impact

      Previously accrued but unwithdrawn vested/unlocked tokens can be erased and recovered or frozen; conversely, rate increases can retroactively accelerate all prior elapsed time and create unintended windfalls.

      Description

      Rate updates are applied to the entire elapsed period since the original start time, which can erase already accrued entitlements or create unintended windfalls. No checkpoint records the amount vested or unlocked before the rate change.

  • Nibiru Go Nibiru Chain Cosmos modules. Tasks 10 V12 6/10 Claude 3/10 Codex 5/10 Pashov —
    Read the audit report
    • EVM base fee is set to 1 wei instead of 1e12 wei nibiru-1 critical
    • Remaining EVM gas refund is created in wei instead of unibi, causing excessive refunds nibiru-2 · 1 writeup critical

      V12

      Gas Refund Unit Mismatch

      app/evmante/evmante_gas_consume.go:103 sev: critical

      Root cause

      RefundGas treats a wei-denominated refund amount as an sdk.Coin amount in the native denom. The refund path omits the evm.WeiToNative conversion that VerifyFee uses on the charge path.

      Impact

      An attacker can submit valid EVM transactions with large gas limits and low actual gas use to receive refunds far exceeding the amount charged upfront. The excess is paid from the fee collector module account, allowing repeated transactions to drain accumulated fee funds whenever the fee collector has sufficient balance.

      Description

      VerifyFee charges transaction gas in the native EVM denom by converting the transaction’s effective wei fee through evm.WeiToNative before returning sdk.Coins. After execution, ApplyEvmTx computes unused gas and calls RefundGas with that unused amount. RefundGas multiplies unused gas by msg.GasPrice(), which is a wei-denominated Ethereum gas price, but then constructs sdk.NewCoin(denom, sdkmath.NewIntFromBigInt(remaining)) directly without converting wei back to native units. Because the code defines 1 unibi == 10^12 wei, every refunded wei amount is paid as native denom units, over-refunding unused gas by a factor of 10^12 relative to the fee that was originally deducted.

    • Dirty EVM journal entries are not applied to Cosmos state in precompiles, enabling infinite unibi minting nibiru-3 critical
    • Precompile bankSend modifies Cosmos state which can be overwritten by outdated EVM state nibiru-4 · 1 writeup critical

      Codex harness (GPT-5.5)

      SDK precompile side effects are not reverted with the EVM call frame

      sev: high

      Root cause

      Mutating precompiles write directly to the parent SDK context instead of journaling SDK-side effects with the EVM call frame or committing them only if the EVM frame succeeds.

      Impact

      EVM contracts can make SDK-side Wasm and bank/FunToken state changes survive an EVM subcall revert that the caller catches. This breaks EVM atomicity across the EVM/Wasm/bank boundary and can let contracts observe a failed low-level call while its external effects are still committed, bypassing Solidity-level accounting and access-control assumptions for cross-VM integrations.

      Description

      The Wasm and FunToken precompiles pull the SDK context directly from StateDB and execute SDK keeper operations against that context. Those SDK writes are outside the EVM StateDB journal. The EVM journal only snapshots and reverts StateDB entries with Snapshot / RevertToSnapshot; it does not snapshot the Cosmos SDK multistore. ApplyEvmMsg later commits only the dirty StateDB account/storage objects when commit is true. As a result, a successful top-level EVM transaction can catch a reverted child call after SDK keeper writes from a precompile have occurred, and those writes remain in the transaction context.

    • On-chain gas limit estimation for internal EVM calls can be abused for DoS nibiru-5 · 3 writeups high

      V12

      Estimation Replays Underpriced Precompiles

      x/evm/keeper/erc20.go:241 sev: high

      Root cause

      EstimateGasForEvmCallType and computeCommitGasLimit reuse the full EVM/precompile execution path for simulations without charging a requester or reconciling custom precompile native work. The binary-search estimator repeats ApplyEvmMsg(commit=false) executions under attacker-selected calldata and gas bounds.

      Impact

      Unauthenticated gas-estimation requests and internal helper estimates can force validators or RPC nodes to execute the same underpriced precompile work multiple times per request. Attackers can amplify expensive ERC20 or Wasm execution through estimator retry/search behavior without paying transaction fees for that work.

      Description

      computeCommitGasLimit runs before committed internal ERC20 helper execution and calls EstimateGasForEvmCallType on a cached context. The public estimator follows the same implementation: it validates only request shape, derives hi from request gas fields or gas cap, and binary-searches by repeatedly invoking an executable closure. Each executable simulation rebuilds the EVM message and calls ApplyEvmMsg with commit=false, which can still dispatch to custom precompiles and their nested/native work. The cache context prevents the estimator from intentionally committing StateDB writes, but it does not make the simulated CPU, EVM, ERC20-helper, or Wasm keeper work paid by an ante fee. This is a distinct pre-execution simulation surface from the normal transaction undercharge in the seed findings.

      Claude Code harness (Opus 4.7)

      Precompiles invoke inner EVM calls outside the caller's gas budget

      sev: high

      Root cause

      FunToken.bankSend invokes ERC20 Transfer/Burn; CallContractWithInput creates a new geth message with an independent gas limit from computeCommitGasLimit/estimation. Inner ApplyEvmMsg runs within that limit and its gas is not deducted from the caller's leftoverGas. The precompile itself charges only a flat or small per-byte RequiredGas.

      Impact

      Attackers can pay low gas while forcing expensive nested EVM execution and bank work. Estimation also doubles execution and is uncompensated. This compounds with H-01.

      Description

      Precompiles create fresh inner EVM messages with their own gas limits and do not deduct the inner gas from the outer EVM transaction.

      Codex harness (GPT-5.5)

      Wasm and FunToken precompile execution is effectively unbounded by EVM gas

      sev: high

      Root cause

      SDK/Wasm and nested EVM work performed by precompiles uses an infinite or separately estimated gas meter instead of being bounded by and charged to the outer EVM gas remaining.

      Impact

      A caller can make validators perform expensive cross-module work without the work being bounded by the transaction's EVM gas limit. This can be used for underpriced execution and block/validator denial of service.

      Description

      The EVM ante handler replaces the SDK gas meter with eth.NewInfiniteGasMeterWithLimit(gasWanted). That meter tracks consumed gas, but it does not enforce the limit: IsPastLimit and IsOutOfGas always return false, and GasRemaining returns math.MaxUint64. Normal EVM execution is bounded by geth's internal gas budget, but precompiles that call SDK modules or launch nested EVM execution are not. The Wasm precompile charges only flat/input-size gas before calling the Wasm keeper directly with the infinite SDK gas meter. The FunToken precompile charges a constant TxGas * 2, then calls arbitrary mapped ERC20 code through nested execution and performs bank keeper operations; the nested ERC20 call's gas is not charged back to the outer EVM frame.

    • Gas spent in internal EVM calls is not charged to the user for MsgCreateFunToken and MsgConvertCoinToEvm nibiru-6 · 1 writeup high

      V12

      Conversion ERC20 Helpers Undercharged

      x/evm/keeper/msg_server.go:512 sev: high

      Root cause

      convertCoinNativeERC20 performs ERC20 balanceOf and transfer helper calls through internal EVM execution but does not charge or reconcile their GasUsed in the surrounding Cosmos message. The helper gas is bounded for the internal call but not converted into proportional transaction gas for the conversion caller.

      Impact

      A mapped ERC20 with expensive balanceOf or transfer logic can force conversion transactions to execute disproportionate internal EVM work. Attackers can repeatedly convert through ERC20-origin mappings to consume validator resources without paying gas proportional to the helper executions.

      Description

      For ERC20-origin FunTokens, ConvertCoinToEvm routes to convertCoinNativeERC20. That settlement path reads the recipient balance, escrows bank coins, reads the module's ERC20 balance, executes an ERC20 transfer from EVM_MODULE_ADDRESS to the recipient, and reads the recipient balance again before burning bank coins. Each balanceOf and transfer goes through the shared ERC20 helper machinery, which constructs and applies an internal EVM message. The conversion handler uses the boolean and balance outputs for accounting checks, but it does not consume or propagate the helper messages' EVM gas usage to the surrounding SDK transaction. This differs from seed 2 because the entry point is a Cosmos conversion message and the helper sequence is read-read-transfer-read from module custody, not a bankSend caller-to-module transfer.

    • Precompiles use the global Cosmos gas meter instead of a local meter, allowing gas-limit evasion nibiru-7 · 2 writeups high

      V12

      Wasm Gas Is Undercharged

      x/evm/precompile/wasm.go:69 sev: high

      Root cause

      The Wasm precompile has no reconciliation layer between CosmWasm execution gas and EVM gas. RequiredGas is static and input-size based, and the transaction finalization path refunds and records gas from EVM accounting rather than the actual Wasm keeper work performed.

      Impact

      An attacker can consume disproportionate validator execution resources while paying and occupying block gas as if only a small precompile call occurred. By setting a large gas limit to keep the Cosmos gas meter from aborting and then receiving the unused EVM gas refund, the attacker can cheaply degrade block processing and transaction availability.

      Description

      precompileWasm.RequiredGas delegates to a generic gas calculator that charges only a flat KV read/write cost plus a per-byte cost for ABI input length. The write paths then invoke CosmWasm Execute, Instantiate, and repeated Execute calls directly from the precompile, so the amount of Wasm VM work is not measured and deducted from EVM gas. ApplyEvmTx later refunds the sender from msg.Gas() - evmResp.GasUsed and resets the Cosmos gas meter to the EVM gas total, making the static precompile gas result the value that drives final charging. Inference: because these calls execute arbitrary CosmWasm contracts through the Wasm keeper, an attacker can choose Wasm messages whose CPU/storage cost is far larger than their calldata-size-based precompile charge. executeMulti amplifies this by dispatching an attacker-controlled list of Wasm executions inside one precompile call while the precompile gas formula remains input-size based.

      Codex harness (GPT-5.5)

      Wasm and FunToken precompile execution is effectively unbounded by EVM gas

      sev: high

      Root cause

      SDK/Wasm and nested EVM work performed by precompiles uses an infinite or separately estimated gas meter instead of being bounded by and charged to the outer EVM gas remaining.

      Impact

      A caller can make validators perform expensive cross-module work without the work being bounded by the transaction's EVM gas limit. This can be used for underpriced execution and block/validator denial of service.

      Description

      The EVM ante handler replaces the SDK gas meter with eth.NewInfiniteGasMeterWithLimit(gasWanted). That meter tracks consumed gas, but it does not enforce the limit: IsPastLimit and IsOutOfGas always return false, and GasRemaining returns math.MaxUint64. Normal EVM execution is bounded by geth's internal gas budget, but precompiles that call SDK modules or launch nested EVM execution are not. The Wasm precompile charges only flat/input-size gas before calling the Wasm keeper directly with the infinite SDK gas meter. The FunToken precompile charges a constant TxGas * 2, then calls arbitrary mapped ERC20 code through nested execution and performs bank keeper operations; the nested ERC20 call's gas is not charged back to the outer EVM frame.

    • Cosmos SDK state changes made by precompiles are not reverted when the EVM call reverts nibiru-8 · 2 writeups high

      V12

      FunToken Effects Bypass Reverts

      x/evm/statedb/statedb.go:56 sev: critical

      Root cause

      bankSend executes ERC20 and bank keeper side effects against the SDK context obtained from StateDB.GetContext instead of a context/journal scoped to the enclosing EVM call frame. Nested ERC20 helpers are explicitly committed with commit=true, while StateDB snapshot reverts only its own journaled EVM objects.

      Impact

      A contract can intentionally revert after a successful FunToken conversion and still leave ERC20 custody/burn or bank-coin mint/send effects applied. This breaks bridge accounting atomically and can lead to direct fund theft or permanent insolvency depending on the mapped token origin and settlement branch.

      Description

      The FunToken precompile retrieves the transaction sdk.Context from StateDB.GetContext and passes it into bankSend. Inside bankSend, the precompile first calls the ERC20 Transfer helper, then either calls the ERC20 Burn helper for coin-origin mappings or mints bank coins for ERC20-origin mappings, and finally sends bank coins from the module account to the recipient. The ERC20 Transfer and Burn helpers execute internal EVM messages with commit=true, causing their internal StateDB writes to commit independently of the caller's outer EVM frame. The outer EVM snapshot/revert mechanism in StateDB replays only its own journal and commits only dirty StateDB accounts/code/storage, so a contract can call bankSend and then revert the enclosing EVM call without reverting the native ERC20 or bank side effects performed through the live SDK context.

      Codex harness (GPT-5.5)

      SDK precompile side effects are not reverted with the EVM call frame

      sev: high

      Root cause

      Mutating precompiles write directly to the parent SDK context instead of journaling SDK-side effects with the EVM call frame or committing them only if the EVM frame succeeds.

      Impact

      EVM contracts can make SDK-side Wasm and bank/FunToken state changes survive an EVM subcall revert that the caller catches. This breaks EVM atomicity across the EVM/Wasm/bank boundary and can let contracts observe a failed low-level call while its external effects are still committed, bypassing Solidity-level accounting and access-control assumptions for cross-VM integrations.

      Description

      The Wasm and FunToken precompiles pull the SDK context directly from StateDB and execute SDK keeper operations against that context. Those SDK writes are outside the EVM StateDB journal. The EVM journal only snapshots and reverts StateDB entries with Snapshot / RevertToSnapshot; it does not snapshot the Cosmos SDK multistore. ApplyEvmMsg later commits only the dirty StateDB account/storage objects when commit is true. As a result, a successful top-level EVM transaction can catch a reverted child call after SDK keeper writes from a precompile have occurred, and those writes remain in the transaction context.

    • RequiredGas() in funtoken and oracle precompiles does not account for dynamic-type gas usage nibiru-9 · 1 writeup medium

      Claude Code harness (Opus 4.7)

      RequiredGas for precompiles uses arbitrary heuristics

      Root cause

      Each precompile picks gas from rules-of-thumb such as TxGas * 2, TxGas, or KV gas config, not from actual workload.

      Impact

      Not directly exploitable alone, but forms the core underpricing surface when combined with H-01/H-02.

      Description

      Precompile gas costs are based on arbitrary heuristics instead of actual workload.

    • Funtoken precompile does not verify ERC-20 transfer boolean return nibiru-10 · 3 writeups medium

      V12

      Ignored Transfer Return Enables Unbacked Mint

      x/evm/keeper/erc20.go:73 sev: critical

      Root cause

      bankSend ignores the success boolean returned by ERC20().Transfer. The bridge assumes that absence of an EVM revert means ERC-20 escrow succeeded, but ERC-20 permits transfer to return false without reverting.

      Impact

      An attacker can mint unbacked bank coins for a mapped ERC-20 without owning or transferring the underlying ERC-20. If honest users have escrowed that ERC-20 in the module, the attacker can redeem the unbacked bank coins through the reverse conversion path and withdraw the genuine escrowed ERC-20.

      Description

      ERC20().Transfer explicitly decodes and returns the ERC-20 transfer boolean, allowing callers to distinguish a successful transfer from a standards-compliant false return. The FunToken precompile calls this helper to move ERC-20 tokens from the EVM caller into the module account, but discards the returned boolean and only checks the Go error. For a mapped ERC-20 whose transfer returns false instead of reverting on failure, bankSend continues as if escrow succeeded. On mappings created from an ERC-20, the same function then mints funtoken.BankDenom bank coins and sends them to the requested Bech32 recipient, creating bank coins without receiving the backing ERC-20. The reverse conversion path later trusts those bank coins and transfers ERC-20 out of the module escrow when enough genuine escrow exists.

      Claude Code harness (Opus 4.7)

      Discarded ERC20 transfer return value in FunToken.bankSend enables free minting of bank coins

      sev: critical

      Root cause

      erc20Calls.Transfer returns (bool, error). The boolean reflects the actual return value of ERC20.transfer(...). In precompileFunToken.bankSend, the boolean is discarded (_, err = p.evmKeeper.ERC20().Transfer(...)) and only err is checked. For non-standard ERC20 contracts that signal failure by returning false without reverting, the precompile proceeds to mint and send bank coins to the recipient.

      Impact

      For a FunToken mapping where IsMadeFromCoin == false, an attacker controlling or deploying an ERC20 that returns false from transfer can repeatedly call bankSend without losing ERC20 tokens while the precompile mints erc20/<address> bank coins to any Nibiru address. This enables unbounded minting at near-zero cost; the coins are tracked by x/bank, participate in total supply accounting, and can be sent through IBC, traded, or otherwise abused. The same pattern exists for IsMadeFromCoin == true but is practically limited because the trusted module ERC20 reverts on failure.

      Description

      precompileFunToken.bankSend discards the boolean returned by erc20Calls.Transfer and checks only err, so ERC20 transfers that return false without reverting are treated as successful and bank coins are minted/sent.

      Codex harness (GPT-5.5)

      bankSend mints bank coins even when ERC20.transfer returns false

      sev: high

      Root cause

      bankSend ignores the boolean success value returned by ERC20.transfer and treats a non-reverting false return as success.

      Impact

      A mapped ERC20 that returns false instead of reverting can mint unbacked bank coins through the FunToken precompile. This is exploitable against valuable/non-standard ERC20s that signal transfer failure with false, and lets arbitrary registered ERC20 contracts mint their mapped bank denomination without backing.

      Description

      erc20Calls.Transfer correctly unpacks and returns the boolean value from ERC20.transfer. The FunToken precompile discards that boolean and checks only err. If the ERC20 call returns false without reverting, err is nil and bankSend continues. For FunToken mappings created from an ERC20 (IsMadeFromCoin == false), the precompile then mints funtoken.BankDenom bank coins and sends them to the requested Nibiru account even though no ERC20 tokens were escrowed.

  • PDT Staking Solidity ParagonsDAO staking program. Tasks 2 V12 2/2 Claude 2/2 Codex 1/2 Pashov 1/2
    Read the audit report
    • Reward-token registration is irreversible pdt-staking-1 · 2 writeups critical

      V12

      Malformed reward token bricks epochs

      src/contracts/StakedPDT.sol:221 sev: high

      Root cause

      Reward-token registration validates only address nonzero and uniqueness, while epoch rollover unconditionally trusts every registered address to answer balanceOf forever and provides no removal path.

      Impact

      A bad registered reward token can halt epoch advancement and prevent normal reward distribution indefinitely. After the active epoch end time passes, users also cannot stake or unstake through the normal paths while the bad token continues to make distribute revert.

      Description

      registerNewRewardToken permanently appends any nonzero address to rewardTokenList without proving that the address is an ERC20-compatible reward token. distribute later iterates every registered token and performs a high-level IERC20(_token).balanceOf(address(this)) call before it can start the next epoch. A registered EOA, non-token contract, or token whose balanceOf reverts will therefore make every rollover revert at that list entry. The file contains no unregister or quarantine path for a bad reward token, and once an epoch has ended stake and unstake both reject until a successful rollover occurs.

      Claude Code harness (Opus 4.7)

      Reward tokens cannot be removed; a single broken/blacklisting token DoS's the protocol

      src/contracts/StakedPDT.sol:221 sev: medium

      Root cause

      There is no reward-token removal path, and distribute()/claim() interact directly with every token without isolating failures.

      Impact

      A single token whose balanceOf or transfer behavior reverts can freeze distribute() or claim() for every user.

      Description

      Registered reward tokens are permanent, and protocol functions call every registered token.

    • PDT can be set as a reward token and withdrawn by admin pdt-staking-2 · 4 writeups medium

      V12

      Reward role can drain principal

      src/contracts/StakedPDT.sol:56 sev: high

      Root cause

      registerNewRewardToken does not forbid newRewardToken == pdt, and withdrawRewardTokens has no accounting guard that separates registered reward balances from staked principal.

      Impact

      A TOKEN_MANAGER account can withdraw PDT backing active stPDT balances, leaving users with receipt tokens that can no longer be redeemed through unstake. The loss can cover the entire staked principal balance held by the contract.

      Description

      StakedPDT stores users' staked PDT in the contract and mints stPDT receipts one-to-one when stake pulls pdt from the caller. registerNewRewardToken accepts any nonzero address that is not already in rewardTokenList, but it does not reject the immutable pdt token. Once pdt is registered as a reward token, withdrawRewardTokens treats it like any other registered reward token and transfers an arbitrary amount to the TOKEN_MANAGER caller. This crosses the role boundary documented by the code: the reward-token manager can convert user principal into a withdrawable reward token without burning stPDT or reducing user balances.

      Claude Code harness (Opus 4.7)

      pdt can be registered as a reward token, enabling theft of staked principal

      src/contracts/StakedPDT.sol:221 sev: high

      Root cause

      registerNewRewardToken checks only for zero address and duplicates; it does not check newRewardToken != pdt.

      Impact

      If PDT is registered, staked principal is counted as distributable rewards, unstaking/distribution accounting can break, and TOKEN_MANAGER can withdraw the stake pool as reward tokens.

      Description

      The contract permits registering the staking principal token PDT as a reward token.

      Codex harness (GPT-5.5)

      TOKEN_MANAGER can register PDT as a reward token and withdraw all staked principal

      src/contracts/StakedPDT.sol:221 sev: high

      Root cause

      registerNewRewardToken() does not reject the staking token address, and withdrawRewardTokens() does not check whether the registered token is the staking asset or whether the withdrawal would remove user principal.

      Impact

      A TOKEN_MANAGER can drain the staked PDT principal backing all outstanding stPDT, causing complete loss of staked funds and making unstake() unable to return user principal.

      Description

      registerNewRewardToken() accepts any nonzero token address that has not already been registered. withdrawRewardTokens() then allows TOKEN_MANAGER to withdraw any amount of any registered token without checking whether that token is the staking asset or whether the withdrawal would remove user principal. Because the staking token address is public and immutable, a TOKEN_MANAGER can register pdt itself as a reward token and then withdraw the PDT balance held by the staking contract. That PDT balance is the collateral backing all outstanding stPDT receipts minted by stake(). Exploit scenario:

      1. Users stake PDT and receive stPDT. The underlying PDT accumulates in StakedPDT.

      2. TOKEN_MANAGER calls registerNewRewardToken(pdt).

      3. TOKEN_MANAGER calls withdrawRewardTokens(pdt, IERC20(pdt).balanceOf(address(this))).

      4. The underlying PDT principal is transferred to the manager. Users still hold stPDT, but unstake() can no longer return their principal.

      This is a privilege-boundary break: the token manager role is documented as managing reward tokens, but the implementation lets it drain the staked asset itself. A compromised or misconfigured token-manager key can therefore cause complete loss of staked funds.

      Pashov Skills

      StakedPDT sev: medium

      Description

      PDT itself can be registered as a reward token, making staked principal claimable as rewards. registerNewRewardToken only rejects address(0) and duplicate reward tokens, and does not prevent newRewardToken from being the PDT staking token. Once PDT is registered, distribute() reads IERC20(pdt).balanceOf(this), including the entire staked principal, subtracts unclaimedRewards[pdt] = 0, and allows the balance to be paid out through claim's safeTransfer. This can drain principal from the staking contract and cause legitimate unstake calls to revert.

  • Perennial Solidity Perennial perpetuals vault + market. Tasks 3 V12 1/3 Claude 1/3 Codex 0/3 Pashov 1/3
    Read the audit report
    • ERC-4626 inflation attack on Vault perennial-1 · 3 writeups critical

      V12

      First depositor donation capture

      packages/perennial-vault/contracts/Vault.sol:221 sev: high

      Root cause

      The initial-share path in Checkpoint.toSharesGlobal() ignores nonzero checkpoint assets when self.shares is zero. Vault._checkpoint() also includes direct token transfers in initial checkpoint assets before any deposit is pulled or any minimum/dead-share supply is minted.

      Impact

      An attacker can front-run the first legitimate vault deposit, establish an artificially high share price, and extract value from subsequent depositors. Victims receive under-minted shares while the attacker’s initial shares represent a disproportionate claim on vault assets.

      Description

      A fresh vault can mint its first depositor shares at par even when the vault already holds donated assets. Vault._checkpoint() initializes the checkpoint from asset.balanceOf() before the current deposit is pulled, so a direct token transfer made before the first deposit becomes checkpoint assets rather than pending deposit assets. When that checkpoint is later processed, Checkpoint.toSharesGlobal() returns the deposit amount unchanged whenever self.shares is zero, ignoring the donated assets that are already included in the checkpoint. The first depositor can therefore donate assets, deposit a small amount, settle, and own all outstanding shares against both the donation and the deposit; later users deposit against the inflated asset/share ratio and receive fewer shares.

      Claude Code harness (Opus 4.7)

      First-depositor share inflation attack on Vault

      packages/perennial-vault/contracts/Vault.sol:238 sev: high

      Root cause

      The vault uses unprotected exchange-rate-based share minting on the first deposit without dead shares, protocol seed shares, minimum locked amount, or meaningful minimum first deposit. Direct transfers increase checkpoint assets without minting shares, and share minting rounds down.

      Impact

      Theft of subsequent depositors' funds: a first depositor can donate assets directly, cause later deposits to mint 0 shares, and redeem their initial share for the entire vault balance.

      Description

      The vault's first depositor can inflate the share exchange rate through direct token donations so later depositors receive zero shares, then redeem to capture the donated tokens and victim deposits.

      Pashov Skills

      Vault sev: high

      Description

      First-depositor share inflation via donation absorbed by Checkpoint.initialize. _checkpoint (Vault.sol:228) calls currentCheckpoint.initialize(context.global, asset.balanceOf()), and Checkpoint.initialize sets self.assets = balance − (global.deposit + global.assets). Any direct token transfer to the vault between checkpoints is captured into the next checkpoint's assets. Attack: attacker deposits the minimum settlementFee (ε) as first depositor, oracle ticks, attacker holds ε shares; attacker donates D >> ε raw tokens to the vault; victim deposits X < D and at the next checkpoint receives X·muldiv(ε, ε+D) shares — which rounds to 0; attacker redeems ε shares for ε + D + X, profiting X. The G-13 floor (depositAssets ≥ settlementFee) only bounds the minimum deposit, not the donation/deposit ratio.

    • High-volatility ticks can cause bank run due to negative liquidations perennial-2 high
    • Markets missing slippage protection perennial-3 medium
  • Pyth Lazer Solana Rust Pyth Lazer oracle Solana program. Tasks 1 V12 1/1 Claude 1/1 Codex 1/1 Pashov —
    Read the audit report
    • Signature bypass pyth-lazer-solana-1 · 3 writeups critical

      V12

      Unbound Signature Offset Bypass

      lazer/contracts/solana/programs/pyth-lazer-solana-contract/src/lib.rs:192 sev: critical

      Root cause

      signature::verify_message treats message_offset as a binding between the current instruction bytes verified by the ed25519 program and the separate message_data argument, but it never validates that binding. The offset checks are algebraic against attacker-controlled input, while signer and payload extraction are performed from message_data rather than from the exact byte range verified by ed25519.

      Impact

      An attacker can fabricate a VerifiedMessage whose public_key is any currently trusted signer and whose payload is attacker-chosen, without a signature from that trusted signer. Downstream Solana programs or clients that rely on this receiver for Pyth Lazer message authenticity can accept forged oracle payloads as if they were signed by Pyth.

      Description

      pyth_lazer_solana_contract::verify_message exposes both message_data and message_offset as caller-controlled instruction arguments and forwards them unchanged into signature::verify_message. The verifier checks only that the prior ed25519 instruction descriptor uses offsets equal to message_offset + fixed field sizes, and it requires those descriptor instruction indices to point at the current instruction. It then reads the format magic, trusted-signer public key, declared payload size, and returned payload from the separate message_data slice at positions derived by subtracting the same caller-supplied message_offset, which collapses back to fixed offsets inside message_data. Because it never proves that message_offset is the actual byte offset of this message_data argument in the current instruction data, a caller can point the ed25519 program at a shifted byte range while the program trusts and returns an unshifted byte range.

      Claude Code harness (Opus 4.7)

      Signature verification bypass — forgery of arbitrary "verified" Lazer messages

      programs/pyth-lazer-solana-contract/src/signature.rs:160 sev: critical

      Root cause

      verify_message receives a message_data: Vec<u8> argument and reads magic, public_key, message_size, and payload from fixed offsets inside message_data, while only checking that the offsets fed to the ed25519 program are consistent with the user-provided message_offset. The fatal omission: nothing checks that the bytes actually verified by the ed25519 program — i.e. instruction_data[message_offset..] of the current instruction — are the same bytes the contract reads from message_data.

      Impact

      The return value of verify_message is the **only** trust anchor downstream consumers (Pyth Lazer price-consuming programs / CPI callers) have. Forging it lets the attacker inject **arbitrary Pyth Lazer payloads attributed to any trusted signer**, completely defeating the oracle's signature scheme.

      Direct downstream consequences include:

      - Injecting fabricated price updates / price feeds into any protocol that trusts VerifiedMessage from this contract.

      - Bypassing any policy a consumer enforces based on public_key (e.g. signer-specific feed allow-lists).

      - Equivalent to compromising the Pyth Lazer trusted signer's private key, for any cross-program caller.

      Cost to the attacker: one transaction's fee + 1 lamport (single_update_fee_in_lamports).

      Description

      verify_message is supposed to ensure that the payload it returns was signed by a trusted signer, but the contract reads public_key / payload from the borsh-deserialized Vec<u8> argument while the ed25519 program reads them from instruction_data[message_offset..]. An attacker can construct separate regions so ed25519 verifies attacker-signed bytes while the contract returns attacker-chosen bytes attributed to a trusted signer.

      Codex harness (GPT-5.5)

      Caller-controlled message offset lets any user forge verified oracle payloads

      programs/pyth-lazer-solana-contract/src/lib.rs:192 sev: critical

      Root cause

      verify_message accepts message_offset from the caller and uses it only to validate the offsets contained in the prior Ed25519 instruction. It never checks that message_offset is the actual offset of the Anchor message_data argument inside the current instruction data.

      Impact

      Any downstream protocol that relies on this program to authenticate Lazer price updates can be fed forged prices or other oracle data, which can directly cause loss of funds, incorrect liquidations, or other protocol-breaking behavior.

      Description

      The receiver can return an attacker-chosen payload as if it was signed by a configured trusted signer. verify_message accepts a caller-controlled message_offset, allowing the Ed25519 precompile to verify bytes at one position in the current instruction while the receiver authenticates and returns different bytes from the message_data argument.

  • SPL Token Rust SPL-Token program — Solana token standard. Tasks 3 V12 1/3 Claude 1/3 Codex 1/3 Pashov —
    Read the audit report
    • Missing check in process_transfer leading to inflationary bug spl-token-1 · 2 writeups critical

      V12

      Reusable Transfer Proofs Inflate Balances

      token/program-2022/src/extension/confidential_transfer/processor.rs:467 sev: critical

      Root cause

      process_transfer omits the post-state ciphertext binding for the source account. The handler verifies proof metadata and subtracts proof-supplied amount ciphertexts, but it never enforces that the stored source available_balance equals the final source ciphertext proven by the ZK transfer instruction.

      Impact

      An attacker can reuse a valid confidential transfer proof to credit a destination account multiple times from a single proven transfer. This inflates confidential token balances and can later be converted through normal confidential accounting flows, breaking supply conservation and enabling direct theft of value from the mint ecosystem.

      Description

      process_transfer accepts a verified confidential-transfer proof and extracts only the transfer amount ciphertexts and participant public keys from it. It never checks the proof’s expected post-transfer source ciphertext against the source account’s new encrypted balance, unlike process_withdraw, which subtracts the plaintext amount and rejects the operation unless the resulting available_balance equals the proof’s final_ciphertext. In both fee and no-fee transfer paths, process_source_for_transfer subtracts the submitted low/high transfer ciphertexts from the current source balance and then immediately stores that result without binding it to a final ciphertext proven by the ZK instruction. A valid proof can therefore be reused against the same source while repeatedly crediting the destination pending balance, because the destination side also just adds the proof-derived destination ciphertexts and increments its credit counter. Once the source encrypted balance is decoupled from the proof statement, repeated executions can create spendable pending balance for the destination without a fresh proof of a valid source post-balance.

      Codex harness (GPT-5.5)

      Transfer proofs are not bound to the source account's on-chain balance

      audit/token/program-2022/src/extension/confidential_transfer/processor.rs:470 sev: critical

      Root cause

      Because the proof program cannot read token account state, it only proves that the transfer is valid with respect to the public inputs supplied in the proof instruction. The token processor extracts transfer ciphertexts and pubkeys from proof_data and subtracts those transfer ciphertexts from the account's stored ciphertext without checking that the proof's source balance matches on-chain state or that the final ciphertext is proof-bound.

      Impact

      This breaks token supply/accounting and can cause direct loss to holders and integrators. A malicious owner can configure and approve a confidential source account with zero available balance, generate a valid transfer proof using an attacker-chosen source balance ciphertext encrypting X, submit the transfer to credit the destination pending balance with X while corrupting the source account, apply the destination pending balance, and withdraw X to the public token balance. No deposited tokens are consumed, and the corrupted source account can be abandoned.

      Description

      A source account owner can create confidential tokens from nothing and later withdraw them as ordinary SPL tokens. The confidential transfer proof is generated against a claimed source_available_balance ciphertext, but the processor never verifies that the claimed ciphertext is the same as the source account's current ConfidentialTransferAccount::available_balance.

    • Missing check in process_withdraw potentially leading to inflationary bug spl-token-2 critical
    • Missing public key check in EmptyAccount leading to deflationary bug spl-token-3 · 1 writeup high

      Claude Code harness (Opus 4.7)

      EmptyAccount does not bind the CloseAccount proof to the account's encryption_pubkey, and does not check withheld_amount before zeroing available_balance

      token/program-2022/src/extension/confidential_transfer/processor.rs:206 sev: medium

      Root cause

      The close-account proof is not explicitly bound to the account's stored encryption pubkey, and the function orders state mutation before the full closability validation.

      Impact

      If account encryption keys can ever be swapped or rotated, a close proof may verify under a pubkey unrelated to the on-chain pubkey. Users with nonzero withheld balances cannot EmptyAccount until harvest/withdraw clears it, an implicit and undocumented burden. Although failed transactions roll back, the ordering makes static reasoning brittle.

      Description

      process_empty_account checks that the account's available_balance ciphertext matches the proof ciphertext but does not compare the proof's ElGamal pubkey to the account's encryption_pubkey. It also zeroes available_balance before calling closable() and does not pre-check withheld_amount.

  • Trufin Rust CosmWasm liquid staking on Injective. Tasks 1 V12 1/1 Claude 0/1 Codex 1/1 Pashov —
    Read the audit report
    • Unstake could be blocked for certain users trufin-1 · 2 writeups medium

      V12

      Reward-Only Withdrawals Emit Zero Undelegations

      contracts/injective-staker/src/contract.rs:142 sev: high

      Root cause

      internal_unstake does not special-case reward-backed withdrawals where actual_amount_to_unstake == 0. It always emits an undelegation message instead of directly creating a claim or paying liquid rewards when the validator principal is exhausted.

      Impact

      A whitelisted attacker can time a max withdrawal after rewards accrue to consume the validator principal and leave other users’ remaining shares backed only by rewards. Those users’ later unstake attempts revert on the zero undelegation message, temporarily freezing their ability to exit until new stake or an operational recovery path restores nonzero delegated principal.

      Description

      The unstake path allows a withdrawal request to be satisfied by validator rewards and CONTRACT_REWARDS after it reduces actual_amount_to_unstake to the validator’s remaining principal. When the selected validator has no principal left but remaining TruINJ shares are still backed by liquid rewards, actual_amount_to_unstake becomes zero while excess_unstaked_amount is accepted against rewards. The function then creates a claim for the user’s full assets_to_unstake, updates reward accounting, burns shares, and still appends a StakingMsg::Undelegate whose amount is zero. A zero undelegation is rejected by the staking module, so the whole transaction fails and the user cannot create the claim. This state is reachable when an earlier large withdrawal consumes all validator principal while leaving other users’ shares backed by rewards held in the contract.

      Codex harness (GPT-5.5)

      Reward-backed shares can become unredeemable when no stake remains

      audit/contracts/injective-staker/src/contract.rs:1671 sev: medium

      Root cause

      In the excess-withdrawal branch, actual_amount_to_unstake is set to validator_total_staked when the withdrawal exceeds the validator stake. If validator_total_staked is zero and CONTRACT_REWARDS is sufficient, the contract reduces CONTRACT_REWARDS and creates a claim, but still emits StakingMsg::Undelegate with zero amount.

      Impact

      Reward-backed shares can be frozen despite the INJ already being in the contract balance. Recovery requires an unrelated new stake or manual upgrade/remediation rather than normal redemption. In the treasury-fee case, if all user stake is withdrawn after rewards have accrued with a nonzero protocol fee, user shares are burned, the treasury receives fee shares, no delegation may remain, and CONTRACT_REWARDS holds the liquid INJ backing the treasury shares. When the treasury tries to redeem those shares, there is no validator stake to undelegate, so the contract attempts a zero-amount undelegation instead of paying from liquid assets.

      Description

      internal_unstake always emits a staking undelegation message, even when the requested withdrawal is fully covered by liquid rewards already held by the contract and the selected validator has no delegated stake. On Cosmos chains, a zero-amount undelegation is invalid, so the transaction reverts and the shares backed by those liquid rewards cannot be redeemed until someone first creates a new nonzero delegation.

  • Valorem Solidity On-chain options clearinghouse. Tasks 3 V12 2/3 Claude 1/3 Codex 3/3 Pashov 2/3
    Read the audit report
    • Un-encoded claimID can be used in write() valorem-1 · 1 writeup high

      Codex harness (GPT-5.5)

      The overloaded write() accepts the option token ID as a claim ID, creating unredeemable lots

      src/OptionSettlementEngine.sol:303 sev: medium

      Root cause

      The existing-claim branch validates only the option key and token balance, but does not require nonzero claim bits or an initialized claim NFT before updating _claim[encodedClaimId].

      Impact

      Users or integrations can permanently lock their own collateral by passing the wrong token ID. The malformed lot is also included in the option's bucket accounting, so exercises against those options can produce additional exercise assets that no one can redeem.

      Description

      The overloaded write(optionId, amount, claimId) validates that the supplied claimId belongs to the same option key, then checks that the caller owns exactly one unit of that token ID. For a valid option ID, the lower 96 claim bits are zero. If the caller owns exactly one fungible option token, balanceOf[msg.sender][optionId] == 1, so the ownership check passes even though optionId is not a claim NFT. The function then takes collateral and mints additional option tokens through the existing-claim branch, but it does not mint a claim NFT. After expiry, redeem(optionId) is impossible because redeem() rejects token IDs whose decoded claimNum is zero.

    • Rounding error in the redeem mechanism valorem-2 · 4 writeups high

      V12

      Bucket Rounding Burns Claims

      src/OptionSettlementEngine.sol:590 sev: critical

      Root cause

      _getAmountExercised() rounds both the exercised and unexercised portions down independently, and redeem() finalizes the claim without preserving or reallocating the rounding remainder.

      Impact

      A claim holder can permanently lose the entire settlement value of a valid claim when bucket ratios round both sides down. The lost underlying and exercise assets remain stranded in the contract, and the effect can be material because a single option contract can represent large underlyingAmount and exerciseAmount values.

      Description

      Claim redemption computes a claim's exercised and unexercised option counts with two independent mulDivDown operations. For a small claim in a partially exercised bucket, both products can round down to zero even though the claim wrote a nonzero number of options. redeem() then marks the claim claimed and burns the claim NFT before transferring only the rounded amounts. The residual exercise assets and underlying collateral are not credited to another claim and are not part of feeBalance, so the burned claim has no recovery path.

      Claude Code harness (Opus 4.7)

      Rounding loss in _getAmountExercised permanently locks dust per bucket

      OptionSettlementEngine.sol:585 sev: medium

      Root cause

      Both exercised and unexercised claim allocations are calculated independently using downward rounding against the same bucket denominator, with no residual sweep to the final claimer.

      Impact

      Dust amounts of underlying and exercise asset can be permanently locked per bucket. Across protocol lifetime and for option types with large underlyingAmount / exerciseAmount factors, the stuck amounts can become economically meaningful and may underfund claimants.

      Description

      The per-claim allocation uses mulDivDown twice, for exercised and unexercised amounts, against the same denominator. Both values round down. The sum across all claimers in a bucket can therefore be strictly less than bucket.amountWritten, leaving small slivers of underlying and exercise asset permanently stuck in the contract. The report provides an example where a bucket with 15 written and 5 exercised leaves 1 unit of each asset stuck forever.

      Codex harness (GPT-5.5)

      Pro-rata bucket rounding can make claim collateral permanently unredeemable

      src/OptionSettlementEngine.sol:592 sev: high

      Root cause

      The redemption algorithm independently floors exercised and unexercised pro-rata shares for each claim, with no residual reconciliation to ensure the sum equals the claim's amount written or the bucket total.

      Impact

      Writers can lose their entire redeemable claim value after ordinary partial exercise patterns. Attackers can also grief other writers by writing small lots into the same bucket and creating fractional bucket ratios that maximize rounding loss. The stranded ERC20 balances are not counted as feeBalance, so sweepFees() cannot recover or redistribute them.

      Description

      Exercise assignment is tracked at the day-bucket level. At redemption time, each claim's share of the bucket is reconstructed in _getAmountExercised() by independently rounding down the exercised share and the unexercised share. Because both legs are rounded down independently, their sum can be less than claimIndex.amountWritten. The missing option units are never assigned to any claim and there is no later reconciliation path.

      Pashov Skills

      OptionSettlementEngine sev: medium

      Description

      Round-to-zero in _getAmountExercised can wipe small writers out of a bucket. Both legs at L592-602 use independent mulDivDown, so for a claim with very small claimIndex.amountWritten relative to bucket.amountWritten both _exercised and _unexercised round to 0 (e.g., bucket {written: 1000, exercised: 1}, claim {written: 1} → both legs round to 0 — small writer loses their entire deposit). The corresponding tokens stay locked in the engine forever, and a griefer can deliberately pad a bucket to wipe other tiny writers.

    • Writing during the exercise period may lead to arbitrage opportunities valorem-3 · 3 writeups medium

      V12

      Late Writers Dilute Assignments

      src/OptionSettlementEngine.sol:293 sev: high

      Root cause

      The bucket accounting model aggregates all same-day writes and exercises but does not snapshot bucket exercise state when adding a new claim to a bucket. Late writes are mixed into buckets that already contain historical exercises, so redemption ratios apply past assignments to future claims.

      Impact

      Late writers can steal a proportional share of already-accrued exercise assets from earlier writers in the same daily bucket. Earlier writers lose settlement proceeds, and the protocol's bucket accounting no longer conserves assignment history across claims.

      Description

      write permits new option lots until expiry and places every write from the same UTC day into the current bucket, even if that bucket already has nonzero amountExercised. When a same-day bucket has been partially exercised, _addOrUpdateClaimBucket increases only amountWritten, leaving the prior amountExercised attached to the enlarged bucket. _addOrUpdateClaimIndex then records the late writer's claim in that same bucket, so _getAmountExercised treats the claim as if it had participated pro rata in exercises that occurred before the claim existed. A buyer can wait until after valuable exercises have already hit a bucket, write new options into that bucket before expiry, and later redeem exercise proceeds attributed to those earlier exercises. This dilutes earlier writers' exercise proceeds and lets late writers receive settlement assets for assignments that could not have consumed their options at the time.

      Codex harness (GPT-5.5)

      Same-day writes after exercises retroactively dilute already-assigned claims

      src/OptionSettlementEngine.sol:552 sev: high

      Root cause

      New writes are merged into an already-exercised same-day bucket, and redemption uses the bucket's final amountWritten and amountExercised rather than immutable write-time tranche state.

      Impact

      Late writers can capture exercise assets paid before their options were written, and earlier writers' claim values can be changed after assignment. This breaks the settlement engine's core accounting invariant: once an exercise has been assigned to a bucket, subsequent writes should not alter who bears that assignment.

      The issue can be used for economic manipulation or griefing whenever exercise and further same-day writes are possible for the same option type. It also makes underlying(claimId) unreliable intra-day because a later write can materially change the returned position for an existing claim.

      Description

      The engine groups all writes from the same day into one OptionsDayBucket. _assignExercise() increments claimBucketInfo.amountExercised when options are exercised. Later, _addOrUpdateClaimBucket() will still merge new writes into the current day bucket even when amountExercised > 0. At redemption, _getAmountExercised() does not use the bucket state as of the claim's write time. It recomputes the claim's exercised and unexercised shares using the bucket's final totals. This means assignments are not final. A write that happens after an exercise can change how prior exercises are distributed among earlier claims.

      Pashov Skills

      OptionSettlementEngine sev: critical

      Description

      Same-day write into an exercised bucket steals exercise proceeds. _addOrUpdateClaimBucket (L640-651) unconditionally adds amount to currentBucket.amountWritten when the last bucket is today's, even after amountExercised > 0; the pro-rata redemption math in _getAmountExercised then redirects part of the already-collected exercise asset to the freshly written claim, stealing from earlier writers. Concrete trace: Alice writes 10 to C1, Carol writes 10 to C2 (B0={20,0}); Bob exercises 20 (B0={20,20}); same day attacker writes 5 to new claim C3 → B0={25,20}. At redemption C3 receives mulDivDown(20, 5, 25) = 4 exercise asset + 1 underlying for a 5-underlying deposit, while C1/C2 each lose 2 exercise asset.

  • Voyage Solidity NFT lending diamond. Tasks 16 V12 12/16 Claude 11/16 Codex 8/16 Pashov 4/16
    Read the audit report
    • Public pullToken function allows stealing ERC20 tokens for which Voyage has approval voyage-1 · 4 writeups critical

      V12

      Arbitrary Allowance Theft

      contracts/shared/util/PeripheryPayments.sol:14 sev: critical

      Root cause

      pullToken() exposes a generic safeTransferFrom() primitive without binding from to msg.sender or applying any protocol authorization. The helper relies only on pre-existing ERC20 allowance to the diamond, which is not caller authorization for arbitrary third parties.

      Impact

      An attacker can drain ERC20 balances from any account that has granted allowance to the Voyage diamond. This is direct theft of user funds and applies to normal approvals created for deposits, purchases, or permit-assisted interactions.

      Description

      PeripheryPayments.pullToken() is a public payable function that lets the caller choose the ERC20 token, the from address, the recipient, and the amount, then executes token.safeTransferFrom(from, recipient, amount). PaymentsFacet inherits this helper, and the deployment script adds PaymentsFacet as a diamond facet, making the selector externally reachable on the Voyage diamond. Because the token call is made by the diamond contract, the only authorization checked by the ERC20 is whether from has approved the diamond, not whether the current caller is the owner or an intended spender. Any user who approved the diamond for normal protocol actions can therefore have that allowance spent by an arbitrary attacker to any recipient.

      Claude Code harness (Opus 4.7)

      PaymentsFacet / PeripheryPayments expose the Voyage diamond's funds and any third-party token allowance to anybody (drain primitive)

      contracts/shared/facets/PaymentsFacet.sol:15 sev: critical

      Root cause

      The payment helper functions are externally exposed through the diamond without access control or restrictions on token/from/to/recipient parameters, while vTokens grant unlimited allowance to the diamond.

      Impact

      Complete loss of every token held by, or approved to, the diamond, including immediate drain of senior/junior deposit pools.

      Description

      Anyone can call public payment helpers on the diamond to sweep ERC20 balances, unwrap WETH, refund ETH, pull tokens from vTokens or any user who approved the diamond, and approve arbitrary spenders.

      Codex harness (GPT-5.5)

      Public payment helpers let anyone steal approved user tokens and sweep protocol balances

      sev: critical

      Root cause

      PaymentsFacet exposes PeripheryPayments publicly; pullToken accepts arbitrary from/recipient without access control or from == msg.sender, and unwrap/sweep/refund helpers are unrestricted.

      Impact

      Direct theft of user-approved funds and protocol-held ETH/ERC20 balances.

      Description

      Any account can transfer tokens from users who approved the Voyage diamond, and can also sweep any ETH/ERC20 balance held by the diamond.

      Pashov Skills

      DiamondVersionFacet sev: critical

      Description

      PeripheryPayments.pullToken is publicly callable and lacks access control while accepting arbitrary from and recipient arguments. Any actor can call the diamond with a victim address that has approved it and an attacker-controlled recipient, causing the diamond to execute safeTransferFrom(victim, attacker, amount). This drains allowances granted by LPs or borrowers for flows such as LiquidityFacet.deposit and LoanFacet.repay, matching the root cause of the Voyage exploit.

    • Signature clash allows calls to transferReserve to steal NFT collateral voyage-2 critical
    • Missing calldata validation in buyNow results in stolen NFT voyage-3 · 4 writeups critical

      V12

      Collateral Identity Unchecked

      contracts/voyage/facets/LoanFacet.sol:115 sev: critical

      Root cause

      buyNow trusts caller-supplied collateral identifiers independently from the marketplace order and performs no pre- or post-execution assertion that the vault received _collection/_tokenId. The adapter validation boundary verifies order shape but not collateral identity.

      Impact

      A borrower can obtain senior-tranche liquidity for a purchase without pledging the purchased NFT as enforceable collateral. On default, liquidation either has no valid collateral to transfer or targets an unrelated token, leaving depositors with bad debt while the borrower keeps the purchased asset.

      Description

      buyNow records the caller-supplied _collection and _tokenId as collateral, but the marketplace calldata is never bound to those values. The adapters extract a price and validate only broad order shape, while LibLoan.initDebt immediately marks param.collection and param.tokenId as the lien. MarketplaceAdapterFacet.purchase then executes whatever calldata the selected adapter returns against the marketplace, with no post-purchase check that the vault received the recorded NFT. A borrower can therefore borrow against a supported collection and token id while using _data that buys a different NFT into the vault. Because VaultFacet.withdrawNFT only blocks tokens whose exact nftIndex entry is marked collateral, the borrower can withdraw the actually purchased NFT while the protocol is left with a lien over an asset the vault never received.

      Claude Code harness (Opus 4.7)

      LooksRareAdapter.extractAssetPrice returns the **taker** order price, which is user-supplied

      contracts/voyage/adapter/LooksRareAdapter.sol:89 sev: medium

      Root cause

      Marketplace order validation omits identity checks for maker order and collateral asset.

      Impact

      Protocol collateral records can mismatch the actual NFT acquired by the vault.

      Description

      The adapter does not verify maker order collection/token/currency/signer and can record a loan against one NFT while purchasing another.

      Codex harness (GPT-5.5)

      Marketplace data is not bound to the declared collateral, enabling loans against nonexistent collateral

      sev: critical

      Root cause

      Marketplace adapters validate only limited order shape and do not bind purchased collection/token to buyNow _collection/_tokenId parameters.

      Impact

      Borrowers can receive senior liquidity by selling worthless assets to the vault, default, and leave lenders without the recorded collateral.

      Description

      A borrower can make Voyage record a valuable whitelisted NFT as collateral while the vault actually purchases a different or worthless asset.

      Pashov Skills

      LoanFacet sev: medium

      Description

      buyNow does not bind the marketplace order to the named (_collection, _tokenId). The user-supplied _collection and _tokenId are used to size the loan via extractAssetPrice and to register the lien, but the opaque _data blob is forwarded to MarketplaceAdapterFacet.purchase without any check that the order actually purchases that NFT. A borrower can register a lien against an expensive token while having the vault buy (and freely withdraw) a different, uncollateralised NFT.

    • Missing timelocks can result in stolen NFTs voyage-4 · 3 writeups critical

      V12

      Withdrawal Overclaims Pool Assets

      contracts/voyage/facets/LiquidityFacet.sol:148 sev: critical

      Root cause

      The unbonding claim amount is recomputed from shares after burning shares instead of storing the requested withdrawal asset amount before the burn. claim() also lacks any cooldown enforcement, allowing the inflated claim to be realized immediately.

      Impact

      An attacker with a minority LP position can extract reserve tokens belonging to other liquidity providers whenever the tranche has enough liquid balance. The attack is direct value theft from the pool and leaves remaining LPs with undercollateralized shares.

      Description

      LiquidityFacet.withdraw() exposes VToken.withdraw() as the user withdrawal path for both tranches. VToken.withdraw() first computes the shares required for the requested asset amount, burns those shares, and only then records the unbonding position with pushWithdraw(). pushWithdraw() converts the burned shares back to assets after total supply has already been reduced, while tranche totalAssets() still includes the pool’s underlying balance, so the recorded maxUnderlying can exceed the amount the user requested to withdraw. Because claim() has no cooldown check and transfers up to that inflated maxUnderlying, a liquidity provider can withdraw their shares and immediately claim more reserve tokens than their pro-rata ownership, diluting the remaining LP shares.

      Claude Code harness (Opus 4.7)

      VToken.redeem (inherited from ERC4626) bypasses the cooldown / unbonding mechanism entirely

      contracts/voyage/tokenization/VToken.sol sev: critical

      Root cause

      VToken fails to override or disable ERC4626.redeem.

      Impact

      Depositors can bypass unbonding and instantly drain liquidity, breaking senior-tranche liquidity invariants relied on by loan flows.

      Description

      VToken implements cooldown only by overriding withdraw, but inherited ERC4626 redeem remains callable and lets share holders receive underlying immediately.

      Codex harness (GPT-5.5)

      VToken withdrawals compute claims after burning shares, enabling overclaims or claim DoS

      sev: high

      Root cause

      VToken.withdraw burns shares before pushWithdraw records maxUnderlying using convertToAssets(_shares), which uses the reduced total supply. Partial claim accounting can also subtract more shares than recorded, and the reset branch uses == instead of assignment.

      Impact

      LPs can claim more assets than they withdrew when new liquidity arrives, or claim() can revert and freeze withdrawals.

      Description

      A withdrawing LP's pending claim is computed using a manipulated post-burn exchange rate.

    • Junior depositor funds mistakenly sent to senior depositors voyage-5 critical
    • Inconsistent usage of totalUnbonding leads to lost or under- utilized lender assets voyage-6 · 3 writeups critical

      V12

      Unbonding Uses Share Units

      contracts/voyage/tokenization/VToken.sol:70 sev: high

      Root cause

      totalUnbonding is tracked in share units but is consumed by totalAssets() as if it were an asset amount. The tranche contracts should subtract the asset-denominated reserved claim amount, not the raw pending shares.

      Impact

      Pending withdrawals corrupt the share price whenever assets per share are not one-to-one. This can dilute new depositors or remaining LPs and can also make tranche accounting revert when the raw share count exceeds the available asset balance.

      Description

      VToken stores totalUnbonding by adding the number of burned shares in pushWithdraw(). The concrete tranche implementations subtract totalUnbonding directly from ERC20 asset balances in totalAssets(), even though the variable is not denominated in assets. VToken even defines totalUnbondingAsset() to convert pending shares to assets, but the tranche accounting does not use it. Once the share price differs from exactly one asset per share because of interest, donations, loan losses, or other asset movements, deposits and redemptions price against an incorrect ERC-4626 totalAssets() value. An attacker can time deposits or exits around pending unbonding positions to receive too many shares or redeem against an overstated share price, shifting value to themselves from other tranche holders.

      Claude Code harness (Opus 4.7)

      Junior/Senior totalAssets() mixes "shares" with "assets" (unit confusion → mis-pricing & DoS)

      contracts/voyage/tokenization/JuniorDepositToken.sol:7 sev: critical

      Root cause

      Share and asset units are mixed, and principalBalance is called with the wrong key type.

      Impact

      totalAssets is mispriced, can underflow/revert, DoSing deposits/withdraw quotes, and senior pool assets are understated.

      Description

      totalUnbonding is tracked in shares but subtracted from asset-denominated balances. SeniorDepositToken also queries principalBalance using currency where collection is expected.

      Codex harness (GPT-5.5)

      Inconsistent totalUnbonding units can misaccount lender assets across VToken and SeniorDepositToken

      contracts/voyage/tokenization/VToken.sol; contracts/voyage/tokenization/SeniorDepositToken.sol:46 sev: high

      Root cause

      totalUnbonding is not represented in one canonical unit across tokenization contracts: VToken updates it using share-derived withdrawal accounting, while SeniorDepositToken.totalAssets treats it as asset-denominated accounting.

      Impact

      Lender assets can be overclaimed, frozen, or under-utilized. Withdrawals may revert or remain stuck, SeniorDepositToken asset accounting can understate or misstate available lender assets, and pool share pricing can diverge from the assets actually backing lender positions.

      Description

      The tokenization layer tracks unbonding through share-derived values in VToken while SeniorDepositToken accounting expects totalUnbonding to be handled in asset units. VToken.withdraw burns shares before recording the pending withdrawal claim, so later claim accounting can subtract share-denominated values from totalUnbonding and either underflow or freeze withdrawals. SeniorDepositToken.totalAssets then subtracts totalUnbonding as part of asset accounting, creating a shares-versus-assets mismatch across lender accounting. This can make withdrawals, claim limits, and pool asset availability diverge from the actual assets backing lender shares.

    • Share burn timing in Vtoken can lead to complete loss of funds voyage-7 · 2 writeups critical

      V12

      Withdrawals Overcredit Unbonding Claims

      contracts/voyage/tokenization/VToken.sol:41 sev: critical

      Root cause

      pushWithdraw() derives the claim amount from convertToAssets(_shares) after _burn() has changed the ERC-4626 exchange rate. The code should record the requested _amount or a pre-burn conversion instead of recalculating against post-burn supply.

      Impact

      An attacker holding tranche shares can extract assets from the token contract beyond their pro-rata entitlement. The excess payment directly dilutes or drains the assets backing the remaining depositors' shares and can be repeated while sufficient liquidity remains.

      Description

      VToken.withdraw() computes the number of shares needed for the requested asset amount, burns those shares, and only then calls pushWithdraw(). pushWithdraw() records the pending claim by converting the burned shares back to assets using convertToAssets() after the burn has reduced totalSupply(). Because convertToAssets() divides by the current supply, the post-burn conversion inflates the user's maxUnderlying above the _amount they requested whenever less than the full supply is burned. claim() then pays the inflated maxUnderlying whenever the token contract has enough liquidity, so a liquidity provider can burn a fraction of their shares and receive more assets than those shares were worth before withdrawal. For example, in a 1,000 asset / 1,000 share junior pool, withdrawing 400 assets burns 400 shares, then records 400 * 1000 / 600 = 666 assets as claimable and pays 666, taking the difference from remaining depositors.

      Codex harness (GPT-5.5)

      VToken withdrawals compute claims after burning shares, enabling overclaims or claim DoS

      sev: high

      Root cause

      VToken.withdraw burns shares before pushWithdraw records maxUnderlying using convertToAssets(_shares), which uses the reduced total supply. Partial claim accounting can also subtract more shares than recorded, and the reset branch uses == instead of assignment.

      Impact

      LPs can claim more assets than they withdrew when new liquidity arrives, or claim() can revert and freeze withdrawals.

      Description

      A withdrawing LP's pending claim is computed using a manipulated post-burn exchange rate.

    • Buyers make first interest payment twice voyage-8 · 3 writeups high

      V12

      First Interest Becomes Sweepable ETH

      contracts/voyage/facets/LoanFacet.sol:208 sev: medium

      Root cause

      The purchase funding calculation mixes downpayment principal and interest but only consumes principal for the marketplace payment, while interest distribution is collected through a separate token transfer. The public payment helper exposes stranded ETH with no ownership accounting.

      Impact

      Borrowers overpay the first interest amount and that residual ETH can be stolen by any caller monitoring successful purchases. The leak repeats on every buy-now loan and can also sweep any other ETH that becomes stranded on the diamond.

      Description

      buyNow requires the borrower to provide params.downpayment, which is the first PMT and includes both principal and interest. The function then borrows only outstandingPrincipal, unwraps WETH through PaymentsFacet.unwrapWETH9, and transfers exactly params.totalPrincipal ETH to the vault for the purchase. Since downpayment + outstandingPrincipal equals the purchase principal plus the first-period interest, the first-period interest remains as ETH on the diamond after the purchase funding step. LibLoan.distributeInterest separately pulls the same interest amount from the borrower into the tranche tokens, so the ETH residue is not accounted as protocol income. Any external caller can then invoke the public refundETH helper to receive the diamond's entire ETH balance.

      Claude Code harness (Opus 4.7)

      LoanFacet.buyNow sends totalPrincipal to the vault but only credits the senior pool with outstandingPrincipal

      contracts/voyage/facets/LoanFacet.sol:250 sev: high

      Root cause

      Interest and principal portions of the downpayment are not separated before forwarding purchase funds.

      Impact

      Borrowers are overcharged or buyNow reverts if they lack allowance for the second interest transfer.

      Description

      The first-period interest included in downpayment is forwarded as purchase ETH and then distributed again via transferFrom, double-charging the borrower.

      Codex harness (GPT-5.5)

      First installment interest is charged twice and the extra ETH remains sweepable

      sev: medium

      Root cause

      buyNow collects a downpayment including first principal and first interest, but only sends purchase price onward and then separately distributes the same first interest by transferring it again from the borrower.

      Impact

      Borrowers are charged first interest twice and stranded funds can be stolen via public refund/sweep helpers.

      Description

      Borrowers overpay the first interest installment, and the extra funds are left on the diamond where any account can take them through public payment helpers.

    • Missing stale price oracle check results in outsized NFT price risk voyage-9 · 4 writeups high

      V12

      Stale Oracle Prices Accepted

      contracts/voyage/facets/LoanFacet.sol:150 sev: high

      Root cause

      Loan validation treats oracle prices as timeless and ignores the timestamp returned by getTwap. There is no reserve-level freshness parameter or maximum-age check before using oracle data for borrowing and liquidation.

      Impact

      Old floor prices can authorize loans that current collateral value would not support, pushing losses to liquidity providers when borrowers default. The same stale-data path can misprice liquidation settlement and collateral transfers during market moves.

      Description

      buyNow and liquidate both read a TWAP value and timestamp from the configured PriceOracle, but they only check that the price is nonzero. The timestamp returned by the oracle is assigned into local parameters and never compared against a maximum age or the current block time. PriceOracle.updateTwap is a push update that stores the latest operator-provided value and timestamp, so a stopped or delayed operator leaves an old price valid indefinitely. A borrower can exploit a stale high floor price after a market drop to pass the principal and credit-limit checks for an undercollateralized purchase. Conversely, stale liquidation prices can transfer collateral using obsolete valuation data.

      Claude Code harness (Opus 4.7)

      liquidate does not enforce param.liquidationBonus > 0 / does not validate floor-price freshness

      contracts/voyage/facets/LoanFacet.sol:397 sev: medium

      Root cause

      Oracle timestamp/freshness is ignored.

      Impact

      Liquidations can execute based on stale TWAP data.

      Description

      Liquidation accepts nonzero floor prices regardless of staleness and does not meaningfully validate the liquidation bonus.

      Codex harness (GPT-5.5)

      Oracle timestamps are ignored, allowing stale floor prices to authorize loans and liquidations

      sev: medium

      Root cause

      buyNow and liquidate receive oracle timestamps but do not enforce freshness or maximum oracle age.

      Impact

      Stale inflated prices can authorize undercollateralized purchases after a collection price crash, while stale depressed prices can distort liquidations and produce lender losses.

      Description

      The protocol accepts any nonzero oracle price regardless of age.

      Pashov Skills

      LoanFacet sev: medium

      Description

      buyNow does not check TWAP timestamp staleness. (params.fv, params.timestamp) = priceOracle.getTwap(params.collection) records the TWAP timestamp but only checks fv != 0. Per the protocol's threat model, oracle operators can updateTwap permissionlessly; an inactive or compromised operator can leave a stale price in place and the borrow path will execute against it without detection.

    • Calls to redeem(...) can result in lost depositor funds voyage-10 · 2 writeups high

      V12

      Redeem Bypasses Withdrawal Queue

      contracts/voyage/tokenization/VToken.sol:24 sev: high

      Root cause

      The delayed-withdrawal state machine is applied only to withdraw() and is not enforced in redeem() or claim(). The contract declares cooldown but never records or checks a withdrawal timestamp.

      Impact

      A liquidity provider can front-run an observed liquidation, default, or other loss-realization transaction and remove idle tranche assets in the same block. The loss or liquidity shortfall is shifted to the remaining depositors and can also cause downstream loan or liquidation flows to fail from missing liquidity.

      Description

      VToken defines a cooldown and overrides withdraw() to enqueue burned shares in unbondings instead of transferring assets immediately. The contract does not override ERC-4626 redeem(), so the inherited public redeem() path remains available and burns shares before immediately transferring assets to the receiver. The queued path also has no timestamp in Unbonding and claim() checks only the current token balance, not cooldown. Any share holder can therefore exit through redeem() immediately, or withdraw and claim immediately when liquidity is present, bypassing the intended seven-day unbonding model. This breaks protocol assumptions that tranche liquidity remains locked during the cooldown window.

      Claude Code harness (Opus 4.7)

      VToken.redeem (inherited from ERC4626) bypasses the cooldown / unbonding mechanism entirely

      contracts/voyage/tokenization/VToken.sol sev: critical

      Root cause

      VToken fails to override or disable ERC4626.redeem.

      Impact

      Depositors can bypass unbonding and instantly drain liquidity, breaking senior-tranche liquidity invariants relied on by loan flows.

      Description

      VToken implements cooldown only by overriding withdraw, but inherited ERC4626 redeem remains callable and lets share holders receive underlying immediately.

    • Incorrect calculation in refundGas voyage-11 · 2 writeups medium

      V12

      Shortfall Refund Reverts

      contracts/vault/Vault.sol:125 sev: medium

      Root cause

      The shortfall calculation in Vault.refundGas() subtracts the available WETH from the ETH balance instead of setting the refundable amount to ethBal + balanceWETH9.

      Impact

      A relayed operation that reduces the vault's ETH/WETH balance below the post-call refund can be forced to fail during gas settlement. This breaks the intended best-effort refund behavior and can make gas-sponsored vault operations unavailable or grief the paymaster/relay flow even though the vault still has partial funds to reimburse.

      Description

      Vault.refundGas() tries to handle an underfunded refund by unwrapping the vault's remaining WETH and lowering amountRefundable instead of reverting. The shortfall branch computes amountRefundable = amountRefundable - toUnwrap - balanceWETH9, where toUnwrap is already _amount - ethBal. Algebraically this becomes ethBal - balanceWETH9, so it underflows whenever the vault has more WETH than ETH and the requested refund exceeds the combined liquid balance. VoyagePaymaster.preRelayedCall() checks the vault balance before the relayed call, but postRelayedCall() later computes a refund and calls the vault after the relayed execution path, so a request that leaves the vault underfunded can make the post hook revert instead of taking the remaining funds.

      Claude Code harness (Opus 4.7)

      Vault.refundGas mis-computes the refund when WETH is insufficient (always reverts in the "edge" path)

      contracts/vault/Vault.sol:125 sev: critical

      Root cause

      The available refund is calculated as _amount - toUnwrap - balanceWETH9 instead of ethBal + balanceWETH9 or equivalent.

      Impact

      The paymaster is not paid and user transactions are unwound when the insufficient-WETH branch is entered.

      Description

      When WETH is insufficient, refundGas subtracts the wrong quantity and underflows, reverting the intended best-effort refund path.

    • Missing access control on postRelayedCall leading to ETH transfer from Vault voyage-12 · 1 writeup high

      V12

      Unauthenticated Vault Gas Refunds

      contracts/shared/gsn/VoyagePaymaster.sol:89 sev: critical

      Root cause

      postRelayedCall omits the inherited relayHubOnly access-control modifier while calling the highly privileged Vault.refundGas path. The vault-side authorization trusts the paymaster address and does not authenticate the original RelayHub context.

      Impact

      An external attacker can move ETH and WETH out of any Voyage vault through repeated direct calls to postRelayedCall. The funds are sent to the protocol treasury rather than the attacker, but the user’s vault balance is permanently depleted without the user initiating a relayed transaction.

      Description

      VoyagePaymaster.postRelayedCall is an external function with no relayHubOnly restriction, even though it performs the state-changing refund step. Any caller can supply an ABI-encoded vault address as context, choose arbitrary gasUseWithoutPost and relayData.gasPrice, and force the paymaster to call IVault(vault).refundGas(refund, treasury). The vault accepts this call because refundGas only checks that msg.sender is the configured paymaster, which is true when the call is made from VoyagePaymaster. The vault then sends ETH, and unwraps WETH when ETH is insufficient, to the immutable treasury address. This makes the GSN hook callable as a public vault-drain primitive rather than only as RelayHub settlement.

    • Functions cannot be removed during upgrades voyage-13 · 1 writeup medium

      V12

      Removed Selectors Stay Active

      contracts/voyage/facets/DiamondVersionFacet.sol:49 sev: medium

      Root cause

      getUpgrade() uses the wrong scratch array when collecting current diamond selectors. Current selectors must be appended to existingSelectors; otherwise the remove-diff phase has no inputs.

      Impact

      Deprecated or intentionally removed entrypoints can stay live after an upgrade computed through this facet. If the removed selector corresponds to a vulnerable or privilege-sensitive function, the old attack surface remains exposed despite the registered version snapshot no longer containing it.

      Description

      getUpgrade() intends to compute add, replace, and remove cuts between the stored snapshot and a target diamond. It records each current selector in existingSelectorFacetMap, but it pushes those selectors into newSelectors and never pushes anything into existingSelectors. The removal phase iterates existingSelectors.length, so that loop is always empty for a clean caller state and no FacetCutAction.Remove entries are produced even when the target diamond exposes selectors absent from the registered snapshot. If an operator applies the returned cut list, obsolete functions that should have been removed remain callable on the diamond.

    • Missing access control on multiple PaymentsFacet functions voyage-14 · 4 writeups high

      V12

      Public Sweepers Drain Balances

      contracts/shared/facets/PaymentsFacet.sol:15 sev: critical

      Root cause

      The payment rescue/refund helpers transfer full contract balances but are public/external and lack access control or accounting that ties the balance to the caller.

      Impact

      Any ETH or ERC20 balance held by the Voyage diamond can be claimed by the first caller of these helpers. This can steal protocol residuals and accidentally sent assets, and it also converts any accepted native ETH balance into an externally drainable pool.

      Description

      PaymentsFacet exposes unrestricted balance-sweeping functions that operate on the diamond’s own balances. unwrapWETH9() checks only a caller-supplied minimum and then unwraps the diamond’s entire WETH balance to an arbitrary recipient, while sweepToken() transfers the diamond’s entire balance of any caller-selected ERC20 to an arbitrary recipient. refundETH() sends the diamond’s entire native ETH balance to msg.sender, and the diamond has an unrestricted payable receive() function that can accumulate ETH. Because these functions have no authorised, owner, or internal-caller gate, any external account can drain residual, accidentally sent, or temporarily accumulated ETH/WETH/ERC20 balances from the diamond.

      Claude Code harness (Opus 4.7)

      PaymentsFacet / PeripheryPayments expose the Voyage diamond's funds and any third-party token allowance to anybody (drain primitive)

      contracts/shared/facets/PaymentsFacet.sol:15 sev: critical

      Root cause

      The payment helper functions are externally exposed through the diamond without access control or restrictions on token/from/to/recipient parameters, while vTokens grant unlimited allowance to the diamond.

      Impact

      Complete loss of every token held by, or approved to, the diamond, including immediate drain of senior/junior deposit pools.

      Description

      Anyone can call public payment helpers on the diamond to sweep ERC20 balances, unwrap WETH, refund ETH, pull tokens from vTokens or any user who approved the diamond, and approve arbitrary spenders.

      Codex harness (GPT-5.5)

      Public payment helpers let anyone steal approved user tokens and sweep protocol balances

      sev: critical

      Root cause

      PaymentsFacet exposes PeripheryPayments publicly; pullToken accepts arbitrary from/recipient without access control or from == msg.sender, and unwrap/sweep/refund helpers are unrestricted.

      Impact

      Direct theft of user-approved funds and protocol-held ETH/ERC20 balances.

      Description

      Any account can transfer tokens from users who approved the Voyage diamond, and can also sweep any ETH/ERC20 balance held by the diamond.

      Pashov Skills

      PaymentsFacet sev: medium

      Description

      Permissionless wrapWETH9 enables ETH-to-WETH conversion before draining. wrapWETH9 is public payable with no auth and wraps address(this).balance. While not directly stealing on its own, it composes with the unauthenticated unwrapWETH9/sweepToken to drain ETH residues that those functions could not otherwise touch and to grief flows that depend on the diamond's ETH balance mid-transaction.

    • Multicall can be used to call buyNow with untrusted msg.value voyage-15 · 1 writeup high

      Claude Code harness (Opus 4.7)

      Multicall allows msg.value to be re-used across iterations (free-value bug)

      contracts/shared/util/Multicall.sol sev: high

      Root cause

      delegatecall preserves the original msg.value for every subcall without tracking consumed value.

      Impact

      Dangerous free-value pattern; future payable accounting could be exploitable, and related ETH/WETH balance handling can interact with C-1.

      Description

      Payable functions invoked through multicall can each observe the same msg.value, enabling over-crediting if any current or future facet credits based on msg.value.

    • Lack of reentrancy guards voyage-16 · 1 writeup medium

      Claude Code harness (Opus 4.7)

      LoanFacet.buyNow is not protected against re-entrancy from the marketplace purchase or NFT receive hook

      contracts/voyage/facets/LoanFacet.sol:115 sev: high

      Root cause

      No reentrancy guard and checks-effects-interactions are not consistently followed.

      Impact

      A malicious collection/marketplace path can re-enter buyNow/repay and manipulate in-flight loan state.

      Description

      buyNow executes marketplace and vault calls that can trigger NFT receiver hooks and re-enter the diamond before loan accounting is finalized.

  • Zetachain Go ZetaChain cross-chain modules. Tasks 12 V12 9/12 Claude 5/12 Codex 4/12 Pashov —
    Read the audit report
    • Any ZetaSent events are processed regardless of what contract emits them zetachain-1 · 3 writeups critical

      V12

      Untrusted Event Creates Outbounds

      x/crosschain/keeper/evm_hooks.go:47 sev: critical

      Root cause

      The hook authenticates ZetaSent by ABI decoding alone and never binds the event to the trusted connector address. ProcessZetaSentEvent performs state changes from attacker-controlled log fields before any provenance check.

      Impact

      An attacker can convert a self-emitted ZEVM log into a real outbound bridge transfer without depositing the corresponding ZETA through the connector. This drains the fungible module/TSS-backed liquidity up to the balance available to burn and the external-chain liquidity available for outbound execution.

      Description

      PostTxProcessing scans every EVM receipt log and treats any log that ABI-decodes as ZetaSent as an authoritative withdrawal request. The parser binds to log.Address and only checks the event signature and layout; ProcessZetaSentEvent never verifies that event.Raw.Address is the deployed ZetaConnectorZEVM or the system contract’s configured connector address. A malicious ZEVM contract can emit a lookalike ZetaSent event without executing ZetaConnectorZEVM.send, which is the legitimate path that transfers WZETA-derived native value to the fungible module before emitting the event. If the forged amount is within the fungible module’s bank balance, the hook burns module coins and creates a PendingOutbound CCTX for the attacker-selected destination and amount. The off-chain signer path then signs an outbound transfer for pending ZetaChain-origin ZETA CCTXs using the CCTX amount, so the forged event becomes an external-chain payout request.

      Claude Code harness (Opus 4.7)

      ProcessZetaSentEvent does not validate the emitting contract — anyone can drain ZETA from the fungible module account and trigger fake cross-chain ZETA withdrawals

      x/crosschain/keeper/evm_hooks.go:38 sev: critical

      Root cause

      PostTxProcessing parses every receipt log as a possible ZetaSent event, but unlike ZRC20 withdrawals, ProcessZetaSentEvent does not check that the emitting contract is the legitimate ZetaConnectorZEVM contract.

      Impact

      An attacker can burn or steal the entire ZETA balance held by the fungible module account and force TSS-signed ZETA withdrawals to attacker-controlled addresses on supported destination chains.

      Description

      ProcessZetaSentEvent performs no validation of event.Raw.Address, so any ZEVM contract can emit a fake ZetaSent log with attacker-controlled destination and value parameters.

      Codex harness (GPT-5.5)

      Any ZEVM contract can forge ZetaSent logs and force outbound TSS work

      sev: high

      Root cause

      The hook iterates all logs in every EVM receipt and parses any log with the ZetaSent ABI, constructing a filterer for the log's own address. It does not verify that log.Address equals the configured ZEVM connector address before burning module funds, creating a CCTX, and scheduling outbound processing.

      Impact

      An attacker can emit forged zero-value events to force TSS signing/broadcasting and drain external gas reserves or congest outbound processing. If the fungible module account holds native ZETA, the attacker can set zetaValueAndGas up to that balance, causing module funds to be burned and a corresponding outbound CCTX to be created without transferring WZETA through the connector.

      Description

      The ZEVM post-transaction hook accepts ZetaSent events by ABI signature only. A user-controlled ZEVM contract can emit the same event without calling the real connector. This creates pending outbound CCTXs that the TSS will attempt to execute, allowing gas-drain/liveness attacks with zero-value events and potential theft of any native ZETA balance held by the fungible module account.

    • Bonded validators can trigger reverts for successful transactions zetachain-2 · 3 writeups critical

      V12

      Unbounded Hashes Stall Confirmation

      x/crosschain/keeper/keeper_out_tx_tracker.go:171 sev: high

      Root cause

      Tracker mutation lacks lifecycle binding and resource bounds: arbitrary unique hashes can be accumulated per nonce, and arbitrary tracker removal/recreation lets a single maintainer control hash ordering. The observer code assumes tracker contents are small and trustworthy enough to scan within fixed timeouts.

      Impact

      A valid external outbound transaction can remain unreported to ZetaCore because observers never reach its hash in the poisoned tracker list. The corresponding CCTX remains pending and user funds scheduled for that outbound can be delayed or frozen until manual cleanup removes the bogus hashes or tracker.

      Description

      AddToOutTxTracker appends every unique msg.TxHash to an existing tracker's HashList and does not validate hash format, require one hash per signer, cap the list, or verify that the hash corresponds to the pending outbound nonce. RemoveFromOutTxTracker also lets the same single bonded-validator or admin authority delete any tracker without checking chain support, CCTX existence, or lifecycle state. The EVM observer then iterates tracker.HashList in stored order, queries each hash, and waits up to three seconds for each miss while an outer ninety-second timeout can break the tracker loop before later hashes are reached. A malicious tracker maintainer can delete a legitimate tracker, recreate it with many invalid hashes, and force the legitimate tx hash to be appended after the poison entries. The observer can then repeatedly time out before reaching the real hash, preventing the local confirmation cache from being populated.

      Claude Code harness (Opus 4.7)

      AddToOutTxTracker / RemoveFromOutTxTracker accept a single validator's word — outbound observation can be DoS'd or poisoned

      x/crosschain/keeper/keeper_out_tx_tracker.go:158 sev: high

      Root cause

      The tracker add/remove functions require only IsBondedValidator or AdminKey; they do not use ballots, thresholds, duplicate signer controls, or list size bounds.

      Impact

      A rogue validator can bloat tracker hash lists, poison observer local state with wrong hashes, or remove legitimate tracker entries and suppress outbound observation.

      Description

      A single bonded validator or admin can add arbitrary hashes to or remove entries from outbound transaction trackers without consensus.

      Codex harness (GPT-5.5)

      Single bonded validator can spoof outbound confirmations through OutTxTracker

      sev: high

      Root cause

      MsgAddToOutTxTracker is accepted from any bonded validator, not only the TSS signer/observer quorum, and it does not verify that the (chain, nonce) belongs to an existing pending CCTX or that txHash is the transaction the TSS signed. Off-chain clients then trust tracker entries as candidate outbound transactions and do not verify nonce, sender/spender, destination, amount, or correspondence to the pending CCTX before caching or confirming tracked hashes.

      Impact

      A malicious bonded validator can spoof outbound confirmations, causing CCTXs to be finalized as OutboundMined even though the protocol outbound never happened. If the amount cannot be matched, fake cached receipts can still block normal signing and finalization until manual intervention.

      Description

      A single bonded validator can cause honest zetaclients to report an arbitrary external transaction as the protocol outbound for a pending CCTX. This can finalize a CCTX without the TSS transaction being executed, causing loss of funds/accounting integrity for matching-value gas outbounds, or at minimum a persistent liveness failure for pending outbounds.

    • Sending ZETA to a Bitcoin network results in BTC being sent instead zetachain-3 critical
    • Race condition in Bitcoin client leads to double spend zetachain-4 · 1 writeup critical

      V12

      Unsynchronized Tracker Map Crashes Client

      zetaclient/bitcoin_client.go:46 sev: high

      Root cause

      The shared submittedTx map is treated as cross-goroutine state but reads and writes are not protected by ob.mu or replaced with sync.Map. The client starts the writer goroutine while other scheduler goroutines call the reader path concurrently.

      Impact

      The crash stops the off-chain Bitcoin client process and halts BTC outbound observation/signing until a supervisor restarts it. By keeping outbound trackers active, an attacker can repeatedly trigger the race and cause a recurring bridge outage that temporarily freezes BTC withdrawals and confirmations.

      Description

      BitcoinChainClient stores outbound transaction observations in the plain Go map submittedTx, but the map is accessed from multiple goroutines without using the client mutex. Start launches observeOutTx as a background goroutine, and that goroutine writes ob.submittedTx[outTxID] = *getTxResult for every observed tracker hash. Separately, outbound scheduling calls IsSendOutTxProcessed, which reads the same map and may also post receive confirmations. Go maps are not safe for concurrent read/write access, so an attacker who creates or sustains BTC outbound trackers can cause fatal concurrent map read and map write panics when tracker observation and outbound scheduling overlap.

    • Not waiting for minimum number of block confirmations results in double spend zetachain-5 · 3 writeups critical

      V12

      BTC Deposits Finalize Before Safe Confirmation

      zetaclient/bitcoin_client.go:73 sev: critical

      Root cause

      The Bitcoin inbound observer advances finalized state based only on the current best height and never enforces a minimum Bitcoin confirmation depth before calling PostSend. The unused confCount field makes the intended safety invariant explicit but it is initialized to zero and not checked in observeInTx.

      Impact

      An attacker can place a BTC deposit to the TSS address in a shallow block, have observers mint or route the represented value on ZetaChain, then invalidate the BTC deposit with a reorg/double-spend before adequate confirmations. The attacker can withdraw or trade the minted value while the bridge reserve never receives the corresponding BTC, creating direct reserve insolvency.

      Description

      The Bitcoin observer is configured with confCount = 0 and the inbound watcher never checks the confirmation count of the block it is about to report. Each polling cycle processes lastBN + 1 as soon as GetBlockCount() is greater than lastBN, reports every parsed deposit through PostSend, and then advances lastBlock to that height. When the reported inbound vote finalizes on ZetaCore, a ZetaChain receiver path immediately calls HandleEVMDeposit, which can mint/deposit the represented BTC gas ZRC20 or execute a deposit-and-call based on the attacker-controlled OP_RETURN memo. Because the Bitcoin client does not wait for a safety depth and does not revisit a height after advancing it, a Bitcoin reorg or deliberate double-spend of a just-mined deposit can leave ZetaCore with finalized minted value that is no longer backed by BTC held at the TSS address.

      Claude Code harness (Opus 4.7)

      Bitcoin client observes outbound without confirmation thresholding — reorg risk + first-write-wins poisoning

      zetaclient/bitcoin_client.go:73 sev: high

      Root cause

      ob.confCount = 0 is hardcoded, IsSendOutTxProcessed posts confirmation when Confirmations > 0, and submittedTx is overwritten without a mutex during outbound observation.

      Impact

      Bitcoin reorgs can invalidate outbounds already marked mined, desynchronizing TSS UTXO accounting; fake tracker hashes can also poison local observer state.

      Description

      The Bitcoin outbound confirmation path treats one confirmation as final and stores submitted transaction data in a racy map where the last queried hash wins.

      Codex harness (GPT-5.5)

      Bitcoin observations are finalized after a single block despite having confirmation-depth fields

      sev: medium

      Root cause

      Although confCount is documented as the number of blocks required for confirmation, it is hard-coded to zero. Inbound scanning posts transactions from the current best block immediately with no confirmation-depth delay, outbound confirmation only requires res.Confirmations > 0, and configured Bitcoin confirmation constants are not wired into the client.

      Impact

      A Bitcoin reorg can remove a deposit after ZetaCore has minted/credited the asset, leaving the TSS without final BTC. Similarly, a TSS withdrawal can be reported successful after one confirmation and later disappear in a reorg.

      Description

      Bitcoin deposits and withdrawals are treated as final after inclusion in the current best block. A one-block reorg can invalidate a BTC inbound after ZetaCore has credited it, or invalidate an outbound after ZetaCore has marked it confirmed.

    • Multiple events in the same transaction causes loss of funds and chain halting zetachain-6 critical
    • Missing authentication when adding node keys zetachain-7 · 2 writeups critical

      V12

      Unauthenticated node key registration

      proto/crosschain/tx.proto:13 sev: high

      Root cause

      SetNodeKeys treats possession of any valid account key as sufficient authorization for node-account creation. The stored NodeAccount records are later trusted by off-chain keygen code without an equivalent bonded-validator or observer authorization filter.

      Impact

      An attacker can create many arbitrary node accounts and pollute the TSS signer list consumed by the off-chain keygen flow. This can prevent keygen from completing or cause operators to keygen against an attacker-inflated participant set, disrupting bridge operation and delaying cross-chain outbound processing.

      Description

      SetNodeKeys is an externally reachable crosschain message and is registered in both the legacy handler and gRPC Msg service. The implementation only checks that msg.Creator is a syntactically valid Cosmos account address, then creates a NodeAccount with the caller-supplied PubkeySet if no account exists for that creator. It does not require the creator to be a bonded validator, an observer, an admin, or otherwise authorized before persisting the node-account record. Off-chain keygen code later reads all NodeAccountAll results and feeds each stored secp256k1 key into the TSS keygen request, so arbitrary accounts can enroll attacker-controlled keys into the signer set used by that path.

      Claude Code harness (Opus 4.7)

      SetNodeKeys has no authorization — anyone can create node accounts and pollute keygen

      x/crosschain/keeper/keeper_node_account.go:103 sev: high

      Root cause

      SetNodeKeys validates only bech32 formatting and absence of an existing account; it does not require the caller to be a bonded validator, observer, or other authorized participant.

      Impact

      Attackers can create unbounded phantom node accounts and pollute keygen-related state, making the NodeAccount set meaningless and potentially disrupting legitimate key registration flows.

      Description

      Any account can call SetNodeKeys and register an arbitrary NodeAccount with arbitrary public keys.

    • Missing nil check when parsing client event zetachain-8 · 2 writeups high

      V12

      Unsupported Chain Crashes Watcher

      contracts/evm/zetaconnectoreth/ZetaConnectorEth.abi:439 sev: high

      Root cause

      observeInTX assumes every emitted destinationChainId maps to a configured chain and dereferences destChain before validating it. The file lacks a defensive unsupported-chain branch before using destChain.ChainName.

      Impact

      An attacker can submit a connector send using an unsupported destination chain ID and cause every observer that scans the resulting log to crash. This halts inbound observation and outbound confirmation for the affected zetaclient instance until operators restart it, delaying or freezing bridge processing if enough observers are taken offline.

      Description

      observeInTX trusts the destinationChainId emitted in external ZetaSent logs and immediately dereferences the result of common.GetChainFromChainID without checking for nil. The connector ABI exposes send with a caller-supplied destinationChainId field, so a ZetaSent log can carry a chain ID that is not present in the local default chain catalog. common.GetChainFromChainID returns nil for unknown IDs, and the next line in observeInTX reads destChain.ChainName.String(), which panics. ExternalChainWatcher does not recover panics around observeInTX, so a single malformed observed event terminates the watcher goroutine and, in Go, an unrecovered goroutine panic brings down the process.

      Codex harness (GPT-5.5)

      Unsupported or non-EVM destination chains in connector events can crash all EVM observers

      sev: high

      Root cause

      The EVM watcher trusts the connector event's destination chain ID before validating that it maps to a configured/supported chain. It calls common.GetChainFromChainID, then immediately dereferences destChain.ChainName and EVM ChainConfigs without nil checks. The connector contract emits user-supplied destination chain IDs without restriction.

      Impact

      A public connector call can crash every zetaclient observing the chain. Because progress is persisted only after the block range is processed, clients panic again after restart at the same block, halting inbound observation and cross-chain message processing for the affected chain.

      Description

      Any user who can call the external EVM connector can emit a valid ZetaSent event with an unsupported destinationChainId, or with a supported non-EVM destination such as Bitcoin that has no ChainConfigs entry. When zetaclients scan that block, they dereference nil chain/config pointers and panic, creating a persistent observation halt until the block is manually skipped or patched.

    • Case-sensitive address check allows for double signing zetachain-9 · 1 writeup high

      V12

      Case-variant validator double voting

      x/crosschain/types/messages_tss_voter.go:41 sev: high

      Root cause

      Signer identity is not canonicalized before storage or duplicate checks. CreateTSSVoter counts raw Bech32 strings in Signers instead of decoded account bytes or a canonical address form.

      Impact

      A single bonded validator can count twice in TSS voting, and in a two-validator set can finalize an arbitrary TSS address/pubkey alone. In larger sets, a coalition smaller than the full validator set can replace the intended full-consensus requirement and commit attacker-chosen TSS state.

      Description

      CreateTSSVoter authorizes the caller by decoding msg.Creator and comparing the resulting account bytes to bonded validator operator bytes, but it stores and de-duplicates signer identities as raw strings. MsgCreateTSSVoter.ValidateBasic only requires msg.Creator to decode as a Bech32 account address, and the duplicate check in isDuplicateSigner uses exact == string comparison. Because Bech32 addresses are case-insensitive at the encoding level while this duplicate check is case-sensitive, the same validator key can submit the same TSS vote once with the canonical lowercase address and again with the all-uppercase representation. CreateTSSVoter then finalizes solely when len(tssVoter.Signers) == len(validators), so duplicate case variants count as distinct validators toward the full-consensus threshold and can write the finalized TSS record.

    • No panic handler in Zetaclient may halt cross-chain communication zetachain-10 · 1 writeup high

      V12

      Malformed Bitcoin Receiver Kills Scheduler

      zetaclient/zetacore_observer.go:221 sev: high

      Root cause

      The scheduler records active work before the signer has installed guaranteed cleanup, and the Bitcoin signer performs attacker-influenced receiver parsing before deferring EndTryProcess. Receiver data copied from inbound observations is not validated before it reaches this off-chain parsing boundary.

      Impact

      An attacker can convert malformed BTC-destination CCTXs into deterministic zetaclient crashes or restart loops. While affected clients are down or repeatedly killing themselves, outbound signing and confirmation reporting stop, temporarily freezing bridge withdrawals and reverts.

      Description

      The core scheduler marks an outbound as active before launching the chain signer goroutine. For Bitcoin outbounds, BTCSigner.TryProcessOutTx slices Receiver[2:] before installing its deferred EndTryProcess, so a receiver shorter than two bytes panics the goroutine and terminates the zetaclient process. If the receiver is at least two bytes but not valid hex, the function returns before the defer is registered, leaving the outTxID permanently active in the scheduler. The health monitor later counts stuck active entries and sends SIGINT when more than ten have been active for over two minutes, so a batch of malformed BTC pending CCTXs repeatedly kills restarted clients. The on-chain inbound message validation does not validate receiver length or Bitcoin address syntax before storing the receiver into outbound parameters.

    • Ethermint Ante handler bypass zetachain-11 high
    • Unbonded validators prevent the TSS vote from passing zetachain-12 · 2 writeups medium

      V12

      Unbonded validators block TSS finalization

      app/app.go:327 sev: high

      Root cause

      CreateTSSVoter uses two different validator universes for the same quorum check. It authorizes only bonded validators but finalizes against the count of all validators returned by GetAllValidators.

      Impact

      TSS creation or rotation can be prevented by the presence of non-bonded validators in staking state. This blocks the on-chain TSS record from being written and can stall bridge key setup or recovery flows that depend on finalized TSS state.

      Description

      CreateTSSVoter loads validators with GetAllValidators, then authorizes individual votes through IsBondedValidator, which only accepts validators where v.IsBonded() is true. The same unfiltered validators slice is later used as the denominator for finalization through len(tssVoter.Signers) == len(validators). This creates an inconsistent quorum: only bonded validators are permitted to add signatures, but every validator returned by the staking keeper is counted as required for completion. Whenever the validator set contains any unbonded or unbonding validator returned by GetAllValidators, the number of possible signers is smaller than len(validators), so the TSS record cannot finalize even if every eligible bonded validator votes.

      Claude Code harness (Opus 4.7)

      CreateTSSVoter finalization condition uses *all* validators (incl. unbonded) and a fragile block-bucket session ID

      x/crosschain/keeper/keeper_tss_voter.go:104 sev: high

      Root cause

      GetAllValidators includes bonded, unbonding, and unbonded validators, while finalization checks len(signers) == len(validators). The session ID is derived from the current block bucket, splitting votes around bucket boundaries.

      Impact

      TSS key creation or rotation can fail to finalize under validator churn or near block bucket boundaries, preventing key migration or rotation.

      Description

      TSS voter finalization requires signatures from all validators returned by GetAllValidators and keys votes by a 1000-block bucket session ID.

  • Private Fixture #1 Rust [redacted] Tasks 15 V12 15/15 Claude 6/15 Codex 7/15 Pashov —

    Source redacted (private engagement)

    • [redacted finding] private-1-001 critical
    • [redacted finding] private-1-002 critical
    • [redacted finding] private-1-003 critical
    • [redacted finding] private-1-004 critical
    • [redacted finding] private-1-005 critical
    • [redacted finding] private-1-006 high
    • [redacted finding] private-1-007 high
    • [redacted finding] private-1-008 high
    • [redacted finding] private-1-009 high
    • [redacted finding] private-1-010 high
    • [redacted finding] private-1-011 medium
    • [redacted finding] private-1-012 medium
    • [redacted finding] private-1-013 medium
    • [redacted finding] private-1-014 medium
    • [redacted finding] private-1-015 medium
  • Private Fixture #2 Rust [redacted] Tasks 24 V12 16/24 Claude 10/24 Codex 4/24 Pashov —

    Source redacted (private engagement)

    • [redacted finding] private-2-001 critical
    • [redacted finding] private-2-002 critical
    • [redacted finding] private-2-003 critical
    • [redacted finding] private-2-004 critical
    • [redacted finding] private-2-005 critical
    • [redacted finding] private-2-006 critical
    • [redacted finding] private-2-007 critical
    • [redacted finding] private-2-008 high
    • [redacted finding] private-2-009 high
    • [redacted finding] private-2-010 high
    • [redacted finding] private-2-011 high
    • [redacted finding] private-2-012 high
    • [redacted finding] private-2-013 high
    • [redacted finding] private-2-014 high
    • [redacted finding] private-2-015 high
    • [redacted finding] private-2-016 high
    • [redacted finding] private-2-017 medium
    • [redacted finding] private-2-018 medium
    • [redacted finding] private-2-019 medium
    • [redacted finding] private-2-020 medium
    • [redacted finding] private-2-021 medium
    • [redacted finding] private-2-022 medium
    • [redacted finding] private-2-023 medium
    • [redacted finding] private-2-024 medium
  • Private Fixture #3 Rust [redacted] Tasks 70 V12 35/70 Claude 13/70 Codex 4/70 Pashov —

    Source redacted (private engagement)

    • [redacted finding] private-3-001 critical
    • [redacted finding] private-3-002 critical
    • [redacted finding] private-3-003 critical
    • [redacted finding] private-3-004 high
    • [redacted finding] private-3-005 high
    • [redacted finding] private-3-006 high
    • [redacted finding] private-3-007 high
    • [redacted finding] private-3-008 high
    • [redacted finding] private-3-009 high
    • [redacted finding] private-3-010 high
    • [redacted finding] private-3-011 high
    • [redacted finding] private-3-012 high
    • [redacted finding] private-3-013 high
    • [redacted finding] private-3-014 high
    • [redacted finding] private-3-015 high
    • [redacted finding] private-3-016 high
    • [redacted finding] private-3-017 high
    • [redacted finding] private-3-018 high
    • [redacted finding] private-3-019 high
    • [redacted finding] private-3-020 high
    • [redacted finding] private-3-021 high
    • [redacted finding] private-3-022 high
    • [redacted finding] private-3-023 high
    • [redacted finding] private-3-024 high
    • [redacted finding] private-3-025 high
    • [redacted finding] private-3-026 high
    • [redacted finding] private-3-027 high
    • [redacted finding] private-3-028 high
    • [redacted finding] private-3-029 medium
    • [redacted finding] private-3-030 medium
    • [redacted finding] private-3-031 medium
    • [redacted finding] private-3-032 medium
    • [redacted finding] private-3-033 medium
    • [redacted finding] private-3-034 medium
    • [redacted finding] private-3-035 medium
    • [redacted finding] private-3-036 medium
    • [redacted finding] private-3-037 medium
    • [redacted finding] private-3-038 medium
    • [redacted finding] private-3-039 medium
    • [redacted finding] private-3-040 medium
    • [redacted finding] private-3-041 medium
    • [redacted finding] private-3-042 medium
    • [redacted finding] private-3-043 medium
    • [redacted finding] private-3-044 medium
    • [redacted finding] private-3-045 medium
    • [redacted finding] private-3-046 medium
    • [redacted finding] private-3-047 medium
    • [redacted finding] private-3-048 medium
    • [redacted finding] private-3-049 medium
    • [redacted finding] private-3-050 medium
    • [redacted finding] private-3-051 medium
    • [redacted finding] private-3-052 medium
    • [redacted finding] private-3-053 medium
    • [redacted finding] private-3-054 medium
    • [redacted finding] private-3-055 medium
    • [redacted finding] private-3-056 medium
    • [redacted finding] private-3-057 medium
    • [redacted finding] private-3-058 medium
    • [redacted finding] private-3-059 medium
    • [redacted finding] private-3-060 medium
    • [redacted finding] private-3-061 medium
    • [redacted finding] private-3-062 medium
    • [redacted finding] private-3-063 medium
    • [redacted finding] private-3-064 medium
    • [redacted finding] private-3-065 medium
    • [redacted finding] private-3-066 medium
    • [redacted finding] private-3-067 medium
    • [redacted finding] private-3-068 medium
    • [redacted finding] private-3-069 medium
    • [redacted finding] private-3-070 medium
  • Private Fixture #4 Rust [redacted] Tasks 27 V12 13/27 Claude 2/27 Codex 5/27 Pashov —

    Source redacted (private engagement)

    • [redacted finding] private-4-001 critical
    • [redacted finding] private-4-002 critical
    • [redacted finding] private-4-003 critical
    • [redacted finding] private-4-004 critical
    • [redacted finding] private-4-005 critical
    • [redacted finding] private-4-006 high
    • [redacted finding] private-4-007 high
    • [redacted finding] private-4-008 high
    • [redacted finding] private-4-009 medium
    • [redacted finding] private-4-010 medium
    • [redacted finding] private-4-011 medium
    • [redacted finding] private-4-012 medium
    • [redacted finding] private-4-013 medium
    • [redacted finding] private-4-014 medium
    • [redacted finding] private-4-015 medium
    • [redacted finding] private-4-016 medium
    • [redacted finding] private-4-017 medium
    • [redacted finding] private-4-018 medium
    • [redacted finding] private-4-019 medium
    • [redacted finding] private-4-020 medium
    • [redacted finding] private-4-021 medium
    • [redacted finding] private-4-022 medium
    • [redacted finding] private-4-023 medium
    • [redacted finding] private-4-024 medium
    • [redacted finding] private-4-025 medium
    • [redacted finding] private-4-026 medium
    • [redacted finding] private-4-027 medium
  • Private Fixture #5 C++ [redacted] Tasks 29 V12 13/29 Claude 6/29 Codex 4/29 Pashov —

    Source redacted (private engagement)

    • [redacted finding] private-5-001 critical
    • [redacted finding] private-5-002 critical
    • [redacted finding] private-5-003 critical
    • [redacted finding] private-5-004 critical
    • [redacted finding] private-5-005 critical
    • [redacted finding] private-5-006 critical
    • [redacted finding] private-5-007 critical
    • [redacted finding] private-5-008 critical
    • [redacted finding] private-5-009 high
    • [redacted finding] private-5-010 critical
    • [redacted finding] private-5-011 high
    • [redacted finding] private-5-012 medium
    • [redacted finding] private-5-013 medium
    • [redacted finding] private-5-014 critical
    • [redacted finding] private-5-015 critical
    • [redacted finding] private-5-016 critical
    • [redacted finding] private-5-017 critical
    • [redacted finding] private-5-018 critical
    • [redacted finding] private-5-019 critical
    • [redacted finding] private-5-020 critical
    • [redacted finding] private-5-021 critical
    • [redacted finding] private-5-022 critical
    • [redacted finding] private-5-023 critical
    • [redacted finding] private-5-024 critical
    • [redacted finding] private-5-025 critical
    • [redacted finding] private-5-026 critical
    • [redacted finding] private-5-027 critical
    • [redacted finding] private-5-028 critical
    • [redacted finding] private-5-029 high

Methodology and FAQ

What determines the ‘ground truth’ for what vulnerabilities exist in a codebase?

Each public fixture is derived from a published audit report. Ground truth is the set of vulnerabilities those audits surfaced and the project team accepted.

What about precision?

Precision is difficult to compare across systems because it depends on how a “bug” is defined and judged. Our ground truth is per-task — each task is a single auditor-confirmed vulnerability — so we can score “did the system find this bug?” objectively. On the other hand, we can’t fairly score “was every other finding it emitted a real bug,” because that depends on “what counts as a bug?”, which is subjective.

In practice, V12 is tuned to surface fewer, higher-signal findings. Our internal experience in real world settings flags that precision is greater than 35%, but we don’t lead with that number publicly as it’s difficult to falsify.

What harness was used for Claude Code and Codex?

Our goal was to choose an off-the-shelf harness that would be representative of blockchain security auditing capabilities. For this benchmark, we used the EVMbench harness for Claude Code and Codex. By default, the EVMbench prompts instruct the system to solely focus on loss-of-funds. For fairness, we slightly modified the prompts to remove this constraint. We also first considered using the Claude Code Security Reviewer harness, but we found that it focused too heavily on vulnerabilities oriented around web application security rather than general code auditing, harming performance.

Why are some fixtures redacted?

Some fixtures here are from audits we performed under NDA. They are included in aggregate (so the totals are honest), but project names, descriptions, and per-finding titles are redacted.

How are language groups assigned?

Fixtures are assigned by the primary language of the project codebase: Solidity, Rust, C++, or Go. Private fixtures keep project names and finding details redacted, but their languages are included so the aggregate results are accurate.

Where do the public fixtures come from?

Every public fixture is derived from a published human audit report. The link on each fixture row points to the report PDF. Ground truth is the set of vulnerabilities those auditors surfaced and the project team accepted.

How is detection scored?

A finding is “detected” when the LLM judge matches the system’s emitted finding to the ground-truth vulnerability based on root cause, affected component, and impact. The judge has no access to the run that produced the finding; it sees only the finding text and the ground truth.

A subsequent human review of the judge’s decisions on a sampled subset found no false positives and no false negatives (with a small number of borderline cases that still identified the correct mechanism). Borderlines are treated as detections here.

Type to search.