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.
| 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:
| 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.
| Language | V12 | Claude 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 |
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.
-
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:292sev: 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 externalanteTest.checkTestPasses()call as a failed invariant._checkTestNoRevert()catches every revert from that external call and returnsfalse, 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 useclaim()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:180sev: 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 thechallengersset 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 inchallengerswhile 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 setpendingFailure, compute the bounty, and enable claims reverts before the failure state is persisted.
Claude Code harness (Opus 4.7)
O(N)
_calculateChallengerEligibilityenables gas DoS ofcheckTestcontracts/AntePool.sol:292sev: 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
-
_calculateChallengerEligibilityiterates through the entirechallengers.addressesarray on every failure, allowing an attacker to add many cheap challenger addresses and makecheckTestexceed the block gas limit.
Codex harness (GPT-5.5)
Dust challengers can make failed tests impossible to finalize
contracts/AntePool.sol:187sev: high- Root cause
-
MIN_CHALLENGER_STAKEis 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
-
AntePoolattempts to limit challenger spam withMIN_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 thechallengersiterable 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 inchallengers.addresses. Once enough dust challengers have been inserted, the failure transaction exceeds the block gas limit and reverts beforependingFailure,failedBlock,verifier,_bounty, and_remainingStakeare finalized. The protocol can no longer record the test failure. During this period stakers can still use the normal unstake flow becausependingFailureremains 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, becausecheckTest()only checks membership in the challenger set and the last stake block, not the current challenger balance.
Pashov Skills
AntePoolsev: high- Description
-
_calculateChallengerEligibilityiterates over the fullchallengers.addressesarray in storage, and challenger registration has no effective floor (theMIN_CHALLENGER_STAKEcheck 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 happenscheckTestcan 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_unwrapdoes not validate escrow / token-account / program ownershipprogram/src/processor.rs:247sev: high- Root cause
-
process_unwrapperforms only two PDA checks and does not validateunwrapped_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 escrowbase.owner == expected_authoritycheck that exists inprocess_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_mintcan 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_unwrapvalidates 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)lacksnonReentrantAori.sol:765sev: low- Root cause
-
Missing
nonReentrantmodifier oncancel(bytes32)while_cancelperforms 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 thenonReentrantguard used by other state-changing entry points, despite ultimately performing an external token transfer toorder.offerer.
Pashov Skills
Aorisev: medium- Description
-
cancel(bytes32)is the only fund-transferring entry point that lacks thenonReentrantmodifier.Aori.sol:765declares it asexternal whenNotPaused, while the other state-mutating user-facing functions are protected bynonReentrant. Because_canceltransfersorder.inputTokenback toorder.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
Aorisev: medium- Description
-
_settleOrderdecreases offerer's locked before validating filler-side overflow, leaving an orphan debit on failure.Aori.sol:663-672callsdecreaseLockedNoRevert(which mutatesbalance.lockedand returns true) and only THEN callsincreaseUnlockedNoRevert; on a filler-side overflow (successUnlock == false) the function returns at line 670 without rolling back the offerer's locked decrement, and theorderStatus = Settledwrite at line 673 is skipped. The orderId has already been popped fromsrcEidToFillerFillsbypackSettlement, so settlement cannot be retried and the LZ message is consumed; for cross-chain orders the offerer has no recovery path (sourcecancelis forbidden bysrcEid == dstEid, dst-cancel requiresUnknown) except owneremergencyCancel. The trigger requires the filler's unlocked balance to be nearuint128.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:433sev: critical- Root cause
-
process_evaluate_attestationsderives the transfer-marker PDA but does not enforce thattransfer_account_infoequals 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_attestationsintends to prevent duplicate disbursement by rejecting an existing transfer marker and then creating a marker derived from the reward-manager and transferid. The function only checks that the caller-suppliedtransfer_account_infohas zero lamports before the token transfer, but it never comparestransfer_account_info.keyto the derived transfer PDA. Afterspl_token_transfersucceeds, the function derives the expected transfer seed but discards the derived address and callscreate_accounton the same unchecked caller-supplied account. An attacker who can supply any fresh signer-owned system account astransfer_account_infocan make the marker creation succeed at that arbitrary account while the canonical marker for theidremains absent. The same quorum of stored attestations can then be evaluated again with another fresh transfer account, bypassing the replay protection for the same rewardid.
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.keyequals 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_attestationschecks only whether the caller-suppliedtransfer_account_infocurrently has lamports. The function later derives the expected transfer PDA fromTRANSFER_SEED_PREFIX || id, but discards the derived address. Becausetransfer_account_info.keyis 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 tosystem_instruction::create_accountcan then succeed using the account's normal transaction signature instead of the intended PDA signature. The canonicalT_<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:116sev: critical- Root cause
-
withdrawviolates checks-effects-interactions by making external WETH andmsg.sendercalls before decrementingusersBalanceandtotalStaked. 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
-
withdrawperforms the value-moving interactions for an unwrapped withdrawal before it reduces the caller’s escrow balance. For ETH-backed tokens, the function calls WETH-stylewithdraw(uint256)and then forwards native ETH tomsg.senderwhileusersBalance[msg.sender][token]still contains the pre-withdrawal balance. A malicious contract can use its payable callback to reenterwithdraw(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
withdrawenables full drain of escrow holdingssrc/EscrowBase.sol:136sev: critical- Root cause
-
State variables tracking the user's right to funds (
usersBalanceandtotalStaked) are mutated only after sending ETH or tokens tomsg.sender, and there is nononReentrantguard. - Impact
-
An attacker can repeatedly reenter
withdrawfrom 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
-
withdrawperforms 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:136sev: high- Root cause
-
EscrowBase.withdrawperforms external interactions, including WETH unwrap and ETH transfer with a full-gascall, before updatingusersBalanceandtotalStaked. - 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.withdrawperforms external interactions before it updates the caller's escrow balance andtotalStaked. In the ETH unwrap branch, the contract unwraps WETH and then sends ETH tomsg.senderwith a full-gascallwhile the caller's WETH balance is still unchanged. An attacker can deposit WETH/ETH, callwithdraw(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 secondwithdraw(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 leastamountof WETH liquidity from other users when the reentrant withdrawal executes.
Pashov Skills
EscrowBasesev: medium- Description
-
The non-ETH withdrawal branches also perform external token transfers before the withdrawal accounting is fully finalized. The
safeTransfer(rebase, finalAmt)andsafeTransfer(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
EscrowBasesev: medium- Description
-
The escrow owner holds excessive unilateral power:
withdrawEscrowlets 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:47sev: 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 notpayableand it callsbridgeRouter.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’smsg.valueto fund retryable-ticket submission and L2 gas costs, so normal nonzeromaxGasandgasPricebridge 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)
bridgeTokenArbnotpayable; Arbitrum L1 → L2 bridging cannot supply L2 gassrc/BridgeEscrow.sol:47sev: high- Root cause
-
The wrapper calls payable
IL1GatewayRouter.outboundTransferCustomRefundwithout accepting or forwardingmsg.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
bridgeTokenArbis 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:47sev: high- Root cause
-
bridgeTokenArbis 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
withdrawis gated byonlyNotBroke. The owner callsbridgeTokenArbto 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.bridgeTokenArbis intended to move escrowed L1 tokens to the Arbitrum escrow after the break timestamp, but it calls the Arbitrum gateway router with empty_dataand forwards no ETH. The Arbitrum L1 gateway expects router-supplied user data to contain at leastabi.encode(maxSubmissionCost, callHookData), and it forwardsmsg.valueto fund retryable-ticket submission and execution. In the vendored Arbitrum gateway implementation,L1ArbitrumGateway.outboundTransferCustomRefundparses the router data and calls_parseUserEncodedData;_parseUserEncodedDatadecodes the payload as(uint256, bytes). Passingbytes("")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
BridgeEscrowsev: medium- Description
-
bridgeTokenArbis non-payable and does not forward ETH to the Arbitrum L1 gateway router. The function lacks thepayablemodifier, callsoutboundTransferCustomRefundwithout a{value: ...}payment, and hard-codes the router data argument tobytes(""). 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:47sev: high- Root cause
-
bridgeTokenArb()approves the router address instead of approving the token-specific Arbitrum L1 gateway that executes the ERC20transferFrom. - 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 foraddress(bridgeRouter)and then asks the Arbitrum router to performoutboundTransferCustomRefund. 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 ofBridgeEscrow. 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:182sev: medium- Root cause
-
OnRecvPacketvalidates only that the forward timeout is positive before using it. It lacks a maximum timeout cap for the delayed-ack forwarding state created byForwardTransferPacket. - 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
-
OnRecvPacketaccepts the attacker-controlledforward.timeoutvalue 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 intoForwardTransferPacket.ForwardTransferPacketconverts 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. TheDurationJSON 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 boundTimeout; user can extend forward lifetime far beyond operator defaultmiddleware/packet-forward-middleware/packetforward/types/forward.go:32sev: 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)
RefundPacketKeynamespace shares the entire keeper store with no prefix, mixing with future stored data and with InitGenesis-written keysmiddleware/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:410sev: 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_depositorand never requires_depositor == msg.senderor 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 withsafeTransferFrom(). In open-offer lockers this can force a victim with a sufficient allowance to becomebuyer, setdeposited, 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
!openOfferEthLocker viareceive()src/EthLocker.sol:264sev: low- Root cause
-
receive()does not checkmsg.sender == buyerwhen!openOffer, whilerejectDepositoris 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:429sev: 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 globalbuyeraddress but does not move the existingamountDepositedbalance from the old buyer to the new buyer. In an open-offer escrow, the seller can then callrejectDepositor()for the current buyer address; because the deposit remains recorded under the old address, the function deletesbuyeranddepositedbut refunds nothing. The original tokens remain in the contract while the offer is reopened, and the next depositor can becomebuyerbased on the pre-existing balance because deposit acceptance useserc20.balanceOf(address(this)) + _amount. A colluding new buyer can then approve execution and haveexecute()transfer the orphaned tokens to the seller.
Claude Code harness (Opus 4.7)
updateBuyer/updateSellerdo not migrateamountDeposited, leaving stale entries vulnerable torejectDepositorsrc/EthLocker.sol:297sev: medium- Root cause
-
updateBuyerrewritesbuyerto a new address butamountDeposited[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
amountDepositedentries 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:59sev: 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, andrejectDepositor()relies on that helper to refund an open-offer depositor before the seller can clear them.checkIfExpired()similarly pushes the non-refundable deposit tosellerand the remainder or refundable balance tobuyerin 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:429sev: 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
amountDepositedledger. - Description
-
rejectDepositor()lets the seller reject the current open-offerbuyerby deleting bothdepositedandbuyer, then refunds onlyamountDeposited[_depositor]for the rejected address. Earlier partial depositors can still have positiveamountDepositedentries 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 withbuyer == address(0). When the locker expires,checkIfExpired()treats_isDepositedas false and, in the refundable branch or the non-deposited fallback, transfers the entire remaining balance tobuyer, 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 toaddress(0)at expiry (burnt)src/EthLocker.sol:386sev: high- Root cause
-
rejectDepositor()deletesbuyerwhen_depositor == buyerbut does not refund or clear other depositors.checkIfExpired()later pays the remaining balance/remainder tobuyer, which may beaddress(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
buyertoaddress(0)while residual deposits from other addresses can remain in the contract. At expiry, remaining funds are sent tobuyer, 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 onaddress(this).balance >= deposit, notamountDeposited[msg.sender] >= deposit.TokenLockerhas the same pattern: token deposit functions compute aggregate_balance, setbuyerwhen aggregate_balance >= deposit, and then record_amountfor that depositor. On expiry, both lockers ignore per-depositor accounting and pay the escrow balance tobuyer. - 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,
depositedremains true while the actual balance may fall belowdeposit; the non-refundable expiry path then underflows atbalance - depositand reverts, blocking expiry processing. - Description
-
In open offers, the address that happens to push the aggregate escrow balance over
depositbecomesbuyer, 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 singlebuyer. 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 ETHandtotalAmount = 200 ETH:1. Alice deposits
99 ETH. Because the total balance is belowdeposit, no buyer is assigned.2. Bob deposits
1 ETH. The aggregate balance is now100 ETH, so Bob becomesbuyereven though he contributed only1 ETH.3. The locker expires without execution.
4.
checkIfExpired()transfers the whole100 ETHbalance to Bob, stealing Alice's99 ETH.The non-refundable path is also unsafe. If additional partial contributors deposit after Bob becomes buyer, Bob receives all escrowed value above
depositat 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:429sev: critical- Root cause
-
buyerApprovedis not invalidated whenrejectDepositor()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 byexecute(). - 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 andexecute()transferstotalAmountto the seller. - Description
-
In open-offer escrows,
buyerApprovedis a global boolean rather than approval bound to the currentbuyer. A seller can accept an accomplice as the first buyer, have that accomplice callreadyToExecute(), and then callrejectDepositor()to deletebuyeranddepositedwhile leavingbuyerApprovedset. When a later victim deposits enough tokens to become the new buyer,depositTokens()assignsbuyerto 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 fulltotalAmountto 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, butupdateBuyer(),updateSeller(), andrejectDepositor()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, butupdateBuyer(),updateSeller(), andrejectDepositor()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 callingreadyToExecute().Exploit scenario:
1. Buyer1 accepts an open offer and calls
readyToExecute(), settingbuyerApproved = true.2. The seller calls
rejectDepositor(Buyer1). Buyer1's funds are returned andbuyer/depositedare cleared, butbuyerApprovedremains 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 changesbuyerto a replacement address withupdateBuyer(); the replacement buyer inherits the old approval.sellerApprovedis likewise not cleared afterupdateSeller(), 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:38sev: 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
sellerandbuyerusingsafeTransferETH, which reverts if the recipient rejects ETH.updateSeller()andupdateBuyer()both callcheckIfExpired()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. WhencheckIfExpired()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
-
EthLockeruses push payments during expiry and does not provide a fallback withdrawal path. The address update functions callcheckIfExpired()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
-
EthLockeruses push payments during expiry and does not provide a fallback withdrawal path. If the currentbuyeror, for non-refundable deposits,selleris a contract that reverts on receiving ETH or lacks a payable receive/fallback function, every call tocheckIfExpired()reverts and the escrow cannot be expired. BecauseupdateBuyer()andupdateSeller()callcheckIfExpired()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 senddeposittosellerand the remainder tobuyer, or sends the full balance tobuyerin the refundable case.4. The transfer to the reverting buyer reverts the whole transaction.
isExpiredand the deletion ofdepositedare reverted as well.5.
updateBuyer()cannot be used after expiry because it also enters the same revertingcheckIfExpired()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:49sev: medium- Root cause
-
swap()performs unbounded iteration over globally append-only stake indexes, whilestake()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 positiveamountand creates a newissues[indexEnd]entry for every deposit, then incrementsindexEnd.swap()must iterate from the currentindexEnddown toindexStaron 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 growindexEnd - indexStaruntil the loop inswap()exceeds the block gas limit. Once that threshold is reached, share holders cannot useswap()to redeem through this pool unless privileged intervention resetsindexStarby withdrawing the pool liquidity.
Claude Code harness (Opus 4.7)
Unbounded loops in
unstake(),swap(),getStakingInfo()contracts/StakingPool.sol:60sev: medium- Root cause
-
userIssueIndexentries are pushed instake()and never removed;swap()scans the entire global index range and does not break onceamountB == 0;getStakingInfo()scansuserIssueIndex[user]twice. - Impact
-
A user can be permanently denied exit by sufficiently many historical
stake/unstakecycles. An attacker or heavy use can grief everyone viaswap()ifindexEnd - indexStargrows 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 recordscontracts/StakingPool.sol:128sev: high- Root cause
-
swap()iterates from the globalindexEnddown toindexStaron every call, skips inactive records only inside the loop, does not break afteramountB == 0, andunstake()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
indexEndand leave inactiveIssueentries 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 callswithdraw(), 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
indexStarreset. 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
StakingPoolsev: critical- Description
-
swap()iterates the entireissuesarray (one entry perstake()deposit) fromindexEnddown toindexStar, 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 theswaploop 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:49sev: 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. Becausestake()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 currentindexEnd, making later deposits occupy higher issue indexes.swap()deterministically walks the issue set fromindexEnddownward toindexStar, so the newest active stakes are always filled before older stakes. A non-American attacker who sees a pending profitableswap()can front-run it by staking just enoughissueTokento sit at the highest active index. The victim'sswap()then transfers the victim'sredeemToeknto 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 stakerscontracts/StakingPool.sol:128sev: 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.isStakingcan remain true forever and their only exit isunstake(), which depends on the owner not having calledwithdraw(). - Description
-
swap()walks fromindexEnddown toindexStar + 1, processing newest stakers first and stopping onceamountB == 0, so older stakers only get processed after every newer staker is consumed.
Pashov Skills
StakingPoolsev: medium- Description
-
swapmatches stakes in last-in-first-out order. The loopfor (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:49sev: high- Root cause
-
withdraw()stores the cutoff asindexStar = indexEndeven thoughindexEndis the next unused issue id. Subsequent accounting uses strictindex > indexStarchecks, 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 throughswap()without compensating the skipped staker with shares. - Description
-
withdraw()transfers the pool’s entireissueTokenbalance to the owner and then setsindexStar = indexEnd. BecauseindexEndis the next stake index, the very nextstake()writes itsIssueat exactly the same index thatwithdraw()saved as the liquidation cutoff. All later readers requireindex > indexStar, so that first post-withdrawal stake is excluded fromunstake(),getStakingInfo(), and theswap()distribution loop even thoughstake()incrementspendingLiquidationfor it. A share holder can then callswap()against the inflatedpendingLiquidation; the loop never allocates shares to the skipped staker, but the function still decrementspendingLiquidationand transfers the staker’sissueTokento 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 unrecoverablecontracts/StakingPool.sol:49sev: critical- Root cause
-
withdraw()setsindexStar == indexEnd;stake()stores at the currentindexEndand increments afterward;unstake()uses a strictindex > indexStarpredicate, making the stake at exactlyindexStarunrecoverable. - Impact
-
Any user who interacts with the pool after an admin
withdraw()will lose their deposit immediately onstake(). The funds remain inside the contract but are unreachable to the user, and reachable only to the owner via the nextwithdraw(). - Description
-
After
withdraw()is called,indexStar == indexEnd. The nextstake()stores the new stake at the index equal toindexStar, butunstake()requiresindex > 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 compensationcontracts/StakingPool.sol:52sev: high- Root cause
-
StakingPoolusesindexEndas the next issue id to be written, while active issue records are considered only when their id is strictly greater thanindexStar.withdraw()setsindexStar = indexEnd, but becauseindexEndis the next unused id, this makes the first future stake be written at exactlyissues[indexStar]and excluded by theindex > indexStarchecks inunstake()andswap(). - Impact
-
The first post-withdraw depositor cannot unstake and does not receive share tokens during swaps. A share holder can swap against
pendingLiquidationand 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
StakingPoolsev: critical- Description
-
withdraw()can leave the next stake locked atindex == indexStarand drainable by a laterswap. Whenwithdraw()setsindexStar = indexEndwithout advancingindexEnd, the nextstake()writes toissues[indexEnd], which is equal toindexStar. Bothunstake()and theswaploop use strict>comparisons, so that stake is skipped even thoughpendingLiquidationis increased. A later swap can satisfy thependingLiquidationcheck, 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:31sev: high- Root cause
-
VaultPool.withdraw()is an unrestricted owner-only full-balance sweep of the sameissueTokenreserve 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
issueTokenreserve 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 entireissueTokenbalance and transfers that full amount tomsg.sender, which is restricted only byonlyOwner. The function has no amount cap, timelock, destination constraint, outstanding-share check, or accounting reconciliation before removing the reserve used byreedem().reedem()calculates user payouts fromissueToken.balanceOf(address(this)), so draining that balance removes the backing for all outstanding share redemptions. The same owner can also pause redemptions directly, andwithdraw()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 userscontracts/StakingPool.sol:151sev: critical- Root cause
-
The owner can call
withdraw()at any time and: (1) drains the entireissueTokenbalance of the contract — including funds that users deposited viastake()and never authorized the owner to remove; (2) setsindexStar = indexEndso that the predicateindex > indexStarused insideunstake()andgetStakingInfo()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
onlyOwneris the only thing standing between every staker and the loss of all theirissueToken. No timelock, no liquidation accounting, no whitelist of recoverable assets. - Description
-
The owner can call
withdraw()at any time to drain the entireissueTokenbalance, including user deposits, and setindexStar = indexEnd, making existing user stakes fail theindex > indexStarpredicate inunstake()andgetStakingInfo().
Pashov Skills
StakingPoolsev: critical- Description
-
StakingPoolandVaultPoolexpose broad owner-only privileges: inStakingPoolthe owner canwithdrawallissueTokenliquidity and callsetRateto change the redeemToken/issueToken swap rate; inVaultPoolthe owner canwithdrawall liquidity and_pausethe 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:231sev: critical- Root cause
-
requestDepositbinds asset movement toownerbut binds the resulting claim tocontrollerwithout authenticating that the caller is authorized byowner. 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
ownerset to the victim andcontrollerset 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
-
requestDepositaccepts an arbitraryownerandcontrollerbut never verifies thatmsg.senderis the owner or an approved operator for that owner. The function records the deposit under the attacker-chosencontroller, emits the request, and then pulls base assets fromownerusing the vault's ERC20 allowance. After the basket manager fulfills the request, the controller authorization indepositlets 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 forrequestDeposit, matching the missing trust-boundary check in the implementation.
Claude Code harness (Opus 4.7)
BasketToken.requestDepositsteals tokens from any user who has approved the basketsrc/BasketToken.sol:231sev: critical- Root cause
-
requestDeposit(uint256 assets, address controller, address owner)pulls the underlying asset **fromowner**, but never verifies thatmsg.senderisowneror an approved operator/spender ofowner. - Impact
-
Complete theft of any depositor's approved underlying balance, repeatable for every basket the user has approved.
- Description
-
BasketToken.requestDepositpulls underlying assets from an arbitraryownerwithout 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:253sev: critical- Root cause
-
BasketToken.requestDeposittransfers assets from the suppliedownerbut never checks thatmsg.senderis the owner or an approved operator for that owner. The implementation violates the ERC-7540 requirement thatowner 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.requestDeposittransfers assets from the suppliedownerbut never checks thatmsg.senderis the owner or an approved operator for that owner. The function records the deposit under the arbitrarycontrollerparameter, then pulls assets fromownerusing 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 callsrequestDeposit(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 thatowner 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:323sev: low- Root cause
-
updateBitFlag()lacks theStatus.NOT_STARTEDguard used by other critical configuration setters and mutatesbasketAssets/BasketToken.bitFlagwithout 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 onlykeccak256(abi.encode(baskets, basketTargetWeights)), so the committed rebalance state does not bind thebasketAssetsarray 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. LaterproposeTokenSwap()andcompleteRebalance()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
BasketManagersev: medium- Description
-
updateBitFlagcan be called during an active rebalance because it lacks theMustWaitForRebalanceToCompleteguard used by analogous configuration functions such assetSwapFee,setTokenSwapAdapter, andsetManagementFee. If it runs mid-epoch, the livebasketAssets[basket]array can diverge from the hash-committedbasketTargetWeights[i].length;_isTargetWeightMetthen iterates only overproposedTargetWeights.lengthand 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:202sev: high- Root cause
-
updateBitFlag()mutates the basket asset list without rebuilding the derivedbasketAssetToIndexPlusOneandbasketTokenToBaseAssetIndexPlusOnestate 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’sbasketAssetsarray and callsBasketToken.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 thebasketAssetToIndexPlusOneloop, then later trusted by deposit processing and trade validation. After an inclusive bit-flag expansion, any newly added asset has nobasketAssetToIndexPlusOneentry, 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 wrongbasketBalanceOfslot. If a rebalance attempts to trade a newly added asset, the missing index causesbasketTokenToRebalanceAssetToIndex()to revert, blocking the intended expansion path.
Claude Code harness (Opus 4.7)
BasketManager.updateBitFlagdoes not updatebasketAssetToIndexPlusOnesrc/BasketManager.sol:460sev: high- Root cause
-
updateBitFlagreplacesbasketAssets[basket]with the new asset list, but does not repopulate thebasketAssetToIndexPlusOne[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)
updateBitFlagcorrupts basket asset accounting after asset-set changessrc/BasketManager.sol:203sev: high- Root cause
-
BasketManager.updateBitFlagreplacesbasketAssets[basket]and callsBasketToken.setBitFlag, but does not rebuild all derived basket metadata:basketAssetToIndexPlusOneandbasketTokenToBaseAssetIndexPlusOne. - 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.updateBitFlagreplacesbasketAssets[basket]and callsBasketToken.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 inbasketAssetsbut absent frombasketAssetToIndexPlusOne, 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],Bhas a zero mapping,Cpoints to its old index, and ifCis the base asset, deposits may be credited toB.
- 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:23sev: critical- Root cause
-
deposittrusts the messageamountas payment accounting without binding it to the actualinfo.funds[0].amountreceived 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::Depositaccepts an attacker-controlledamount, butdepositvalidates only that exactly one native fund of the configured denom was attached. It never checks thatinfo.funds[0].amountequals the declaredamount, then creditsuser.amountandstate.total_amountby that declared value.harvestlater calculates the user's offering-token allocation and refund from the storeduser.amountandstate.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
depositdoes not verify the amount of native funds sentcontracts/launchpad/src/contract.rs:154sev: critical- Root cause
-
The native funds validation checks only
info.funds.len()andinfo.funds[0].denom, not thatinfo.funds[0].amountequals the user-suppliedamount. - Impact
-
A single permissionless tx steals (a) every offering token reserved for the sale and (b) all native funds genuine depositors contributed.
- Description
-
depositonly checks the denom of the incoming funds, never the amount.amountis a user-supplied argument fromExecuteMsg::Deposit { amount }, and there is no comparison such asinfo.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:174sev: critical- Root cause
-
The contract trusts the caller-supplied
amountparameter 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
amountparameter and only checks that exactly one coin of the configured denom was sent. It never checksinfo.funds[0].amount == amount. The credited user amount and globaltotal_amountare both increased by the caller-suppliedamount, 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:39sev: high- Root cause
-
admin_configuregrantsFEE_COLLECTORarbitrary write access toPAIR_INFO.asset_infosfor 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::AdminConfigurereachesadmin_configure, which is gated only by equality with the hardcodedFEE_COLLECTORaddress. Once that address calls the function, the contract rebuilds and savesPAIR_INFOwith arbitrary caller-suppliedassetsandasset_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 mutatedPAIR_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_configurecan re‑point the pair to arbitrary assets, freezing user funds and enabling theftcontracts/dojoswap_pair/src/contract.rs:147sev: 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_configureis gated only by equality against a hard-coded fee collector address and rewritesasset_infosandasset_decimalson 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:40sev: 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
-
AdminConfigureon the pair contract checks only thatinfo.senderequals the hard-codedFEE_COLLECTOR, then overwritesPAIR_INFO.asset_infosandasset_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 usePAIR_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
MerkleTreeImplementationsev: medium- Description
-
The cross-chain receipt flow stores all Merkle leaves on-chain:
transmitrecords eachreceiptHashviaIMerkleTree(merkleTree).recordMerkleTreeand persistsleafNodeIndex, 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-transmitstorage and gas overhead.
-
Front-running gas attack can cancel transaction ebridge-2 · 2 writeups medium
V12
Failed Calls Consume Governance Transactions
contracts/MultiSigWallet.sol:182sev: medium- Root cause
-
executeTransaction()performs the state transition toexecuted = truebefore 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 returnssuccess == false, the function emitsExecutionFailurebut never restorestransaction.executedtofalse. The wallet therefore treats a failed governance action as completed and thenotExecutedmodifier blocks any later retry of the same transaction after the target precondition is fixed. This is reachable for normal bridge administration calls becauseconfirmTransaction()automatically invokesexecuteTransaction()as soon as quorum is reached, and protected bridge functions such asaddToken(),removeToken(), andrestart()can revert on state-dependent conditions.
Claude Code harness (Opus 4.7)
MultiSigWallet.executeTransactionsetstransaction.executed = truebefore 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
-
executeTransactionsetstransaction.executed = truebefore 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:207sev: high- Root cause
-
deposit()exposes an admin-style liquidity provisioning flow to arbitrary callers whilewithdraw()anddepositAmountare 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 noonlyOwner,onlyWallet, pause, or receipt-creation guard. It accepts any supportedtokenKey, pullsamountfrommsg.sender, increments the single globaldepositAmount[tokenKey], approvesbridgeOut, and forwards the same tokens intoBridgeOut.deposit(). UnlikecreateReceipt()andgenerateReceipt(), this path does not write aReceipt, does not index a receipt under the depositor, and does not create any per-depositor claim. The only recovery path for this accounting bucket iswithdraw(), which isonlyOwnerand can send the tokens to an arbitraryreceiverAddress, so externally supplied funds become owner-controlled liquidity rather than user-owned bridge receipts.
Claude Code harness (Opus 4.7)
Liquidity providers calling
BridgeIn.depositpermanently lose ownership of the deposit (sole withdrawer isonlyOwner)contracts/BridgeInImplementation.sol:301sev: high- Root cause
-
depositis permissionless and pulls ERC20 frommsg.senderinto the bridge.withdrawisonlyOwnerand may direct funds to anyreceiverAddress. There is no per-user accounting ofdepositAmount; 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
BridgeInImplementationsev: medium- Description
-
BridgeIn.depositis 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 creditsdepositAmount[tokenKey]and forwards toBridgeOut.deposit. No share token is minted, no receipt is emitted, and only the proxy owner (withdrawisonlyOwner) can later pull those tokens out to an arbitraryreceiverAddress. 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:129sev: 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
numExitRequestsByTnftto 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 callingupdateNumExitRequests(1, 0)and overwriting the singleexitRequestTimestamp. A TNFT owner can therefore call the function repeatedly for the same validator, causing the associated safe’snumExitRequestsByTnftcounter to exceed the one timestamp recorded invalidatorInfos. 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 intounusedWithdrawalSafes, making it available for recycling with the stale counter still set. Any future validator assigned that recycled safe will hit_getTotalRewardsPayoutsFromSafe()’snumExitRequestsByTnft() == 0requirement 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:381sev: high- Root cause
-
_cancelDeposit()can route anEXITEDvalidator throughnodesManager.unregisterValidator()without the settlement steps enforced byfullWithdraw(). Empty-safe recycling then keys only onnumAssociatedValidators()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
EXITEDvalidator,_unRegisterValidator()treats it like a full-withdraw cleanup by setting the phase toFULLY_WITHDRAWN, deleting the validator-to-safe mapping, and pushing the safe tounusedWithdrawalSafesif its association count becomes zero. UnlikefullWithdraw(), 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 resetsrestakingObservedExitBlockandisRestakingEnabledfor 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
EXITEDvalidator via the bNFT cancel path locks the safe's principal and breaks the full-withdrawal flowsrc/LiquidityPool.sol:389sev: high- Root cause
-
Cancel logic branches on
WAITING_FOR_APPROVALvs everything else and does not restrict cancellations to pre-live phases;_unRegisterValidatorpermitsEXITED → FULLY_WITHDRAWNand 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
EXITEDvalidator, transitioning it toFULLY_WITHDRAWNand 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:104sev: critical- Root cause
-
batchRegisterValidators(bytes32,uint256[],DepositData[])lacks asourceOfFund == DELEGATED_STAKINGcheck 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 toStakingManager. The publicStakingManager.batchRegisterValidators(bytes32,uint256[],DepositData[])later only checks thatbidIdToStakerInfo[_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 theStakingManagercontract balance, sets the validatorLIVEbecause the TNFT recipient is the caller instead of the liquidity pool, and mints both NFTs to the caller. Any ETH parked inStakingManagerfor 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:298sev: high- Root cause
-
_batchDeposit()recordsmsg.senderas 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
isLpBnftHoldermode is enabled,batchDepositWithLiquidityPoolAsBnftHolder()calls_batchDeposit()with_stakerDepositAmountPerValidatorequal to zero._batchDeposit()still recordsmsg.senderas the BNFT staker inStakingManager, 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-coded2 etherfor each validator still inSTAKE_DEPOSITED, or1 etheronce it is inWAITING_FOR_APPROVAL. BecauseStakingManagerauthorizes 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
batchCancelDepositwithout ever staking anythingsrc/LiquidityPool.sol:268sev: high- Root cause
-
In LP-bNFT mode the staker contributes 0 ETH, yet
StakingManager.bidIdToStakerInforecords the caller as staker andLiquidityPool._batchCancelDepositrefunds 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:202sev: low- Root cause
-
For restaked validators,
partialWithdraw()first claims only up tomaxEigenlayerWithdrawalsqueued 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 becausequeueRestakedWithdrawal()and batch queueing are public. - Impact
-
If more than
maxEigenlayerWithdrawalswithdrawals 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:240sev: high- Root cause
-
VesterNoReserve._updateVestingpasses_accounttoIRestrictedToken(esToken).burneven though_depositescrowed the tokens inaddress(this). The reserve-basedVestercounterpart burns fromaddress(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
-
VesterNoReserveescrows a user'sesTokenduring_depositby transferring the deposited amount from the account into the vesting contract and minting non-transferable vesting shares. When vesting advances,_updateVestingburns vesting shares and marks the amount claimable, but it callsIRestrictedToken(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 throughvestEsGsb,withdrawEsGsb, and aggregateclaim, so a user who has no additional liquidesGsbcannot claim vested GS or withdraw once any nonzero amount has vested, while a user who does have liquidesGsbloses those extra tokens and leaves the originally escrowed tokens stranded in the vester.
Claude Code harness (Opus 4.7)
VesterNoReserve._updateVestingburns esGSb from the user instead of from the contract, bricking the vester (or stealing user wallet balance)contracts/VesterNoReserve.sol:324sev: high- Root cause
-
The function calls
IRestrictedToken(esToken).burn(_account, amount)after deposits have already transferred the user's esGSb toaddress(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._updateVestingburns esGSb from_accounteven though deposited esGSb was transferred into the vester contract. The correct behavior, as inVester.sol, is to burn fromaddress(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
-
VesterNoReserveburns vested escrow tokens from_accounteven 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
VesterNoReservepath used byStakingRouter.vestEsGsb. - Description
-
VesterNoReserve._deposittransfers the user's esGSb into the vester contract and mints non-transferable vesting shares to the user:-
contracts/VesterNoReserve.sol:240-247However, when vesting advances,
_updateVestingburns the underlying escrow token from_account:-
contracts/VesterNoReserve.sol:312-324This differs from
Vester, which correctly burns vested escrow fromaddress(this)after the escrow has been deposited:-
contracts/Vester.sol:375-387Because
VesterNoReservealready holds the deposited esGSb, burning from_accounthas two bad outcomes:1. If the user has no separate esGSb balance outside the vester,
claim()andwithdraw()revert duringIRestrictedToken(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
VesterNoReservepath used byStakingRouter.vestEsGsb.
-
Cancellation of isDepositToken still allows rewards to be claimed gammaswap-staking-2 · 2 writeups medium
V12
Disabled Tokens Keep Earning
contracts/RewardTracker.sol:72sev: 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.
setDepositTokenlacks 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 havedepositBalancesor whethertotalDepositSupplyfor that token is zero. The sameisDepositTokenflag 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 bothunstake()and handler-mediatedunstakeForAccount()route through_unstake(). The locked position still remains instakedAmounts,balances, andtotalSupply, 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
RewardTrackersev: medium- Description
-
setDepositToken(token, false)can permanently prevent existing holders from unstaking that token._unstakerequiresisDepositToken[_depositToken], mirroring the staking gate, so if the owner disables a token while users still have staked balances,unstakeandunstakeForAccountrevert for those users. The affected funds can only be recovered through the owner-controlledStakingAdmin.executepath.
-
-
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:60sev: critical- Root cause
-
The contract applies
nonReentrantto both the external entry point and the private helper it calls. OpenZeppelinReentrancyGuarddoes not allow nestednonReentrantcalls 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 markednonReentrantand callsfinalizeWithdrawal()inside its loop.finalizeWithdrawal()is also markednonReentrant, even though it is a private helper. With OpenZeppelinReentrancyGuard, 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. BecausefinalizeWithdrawal()is private, users cannot bypass the batched entry point to complete a pending withdrawal through another function.
Claude Code harness (Opus 4.7)
batchedFinalizeWithdrawalsis permanently bricked by nestednonReentrant— withdrawals can never be finalizedBridge2.sol:284sev: critical- Root cause
-
batchedFinalizeWithdrawalsis declarednonReentrant, then iterates calling the private functionfinalizeWithdrawal, which is also declarednonReentrant. OpenZeppelin ReentrancyGuard uses a single_statusslot, so the outer call sets_status = _ENTEREDand the inner guard reverts withReentrancyGuard: 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
-
batchedFinalizeWithdrawalscalls privatefinalizeWithdrawal, and both are markednonReentrant, so OpenZeppelin ReentrancyGuard reverts on the nested call. BecausefinalizeWithdrawalis 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
nonReentrantaudit/Bridge2.sol:302sev: high- Root cause
-
Both the external batching function and the internal/private withdrawal finalization helper are protected by the same global
nonReentrantguard, causing the nested call to revert. - Impact
-
Every non-empty call to
batchedFinalizeWithdrawalsreverts 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
-
batchedFinalizeWithdrawalsis the only externally callable function that finalizes requested withdrawals. It is markednonReentrantand callsfinalizeWithdrawalfor each message. However,finalizeWithdrawalis also markednonReentrant. OpenZeppelinReentrancyGuarduses one global guard flag for the whole contract. OncebatchedFinalizeWithdrawalsenters, the guard is set to_ENTERED; the nested call tofinalizeWithdrawalimmediately hits the same modifier and reverts withReentrancyGuard: reentrant call.
-
Disputed actions are not blocked by validator rotation hyperliquid-2 · 1 writeup high
Pashov Skills
Bridge2sev: 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 existingrequestedWithdrawals[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:46sev: critical- Root cause
-
createBridgeAgent()delegates trust establishment to the factory without authenticating the caller or validating that_rootBridgeAgentAddressis an approved root bridge agent. This combines withArbitrumBranchBridgeAgent’s directmsg.senderexecutor 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 throughIPort(localPortAddress).addBridgeAgent(newBridgeAgent). The caller controls both_newBranchRouterAddressand_rootBridgeAgentAddress, and the deployedArbitrumBranchBridgeAgentstores the supplied root agent as its trusted inbound caller. Unlike the base branch agent, the Arbitrum variant authorizes inbound execution solely by checkingmsg.sender == rootBridgeAgentAddress, so an attacker can deploy a trusted branch agent with their own address as the root agent and then callanyExecute()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 therequiresBridgeAgentguard.
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:70sev: 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:46sev: 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:263sev: medium- Root cause
-
anyExecuteSignedDepositMultiplelacks therequiresAgentmodifier 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
-
anyExecuteSignedDepositMultipleis the only active signed multicall execution entrypoint in this router that omitsrequiresAgent. The function is externally callable by any address and still executes arbitraryIVirtualAccount(userAccount).call(calls), withdraws the requested output tokens fromuserAccount, and invokes_approveMultipleAndCallOut. That internal helper then callsRootBridgeAgent.callOutAndBridgeMultiplefrom the trusted router address, crossing theRootBridgeAgent.requiresRouterboundary even though the original caller was not the bridge agent. An attacker can supply a contract that satisfies theIVirtualAccountinterface or exploit router-held hToken balances to drive unauthorized outbound bridge attempts through the root router surface. The adjacent signed entrypoints requiremsg.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:341sev: 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
MulticallRootRoutersev: critical- Description
-
MulticallRootRouter.anyExecuteSignedDepositMultiple is missing
requiresAgent, letting anyone drain VirtualAccounts. Every sibling (anyExecute,anyExecuteSigned,anyExecuteSignedDepositSingle,anyExecuteDepositSingle,anyExecuteDepositMultiple,anyExecuteResponse) carriesrequiresAgent; line 418 declares onlyexternal payable returns (...), so any address can invoke arbitraryIVirtualAccount(userAccount).call(...)andwithdrawERC20on the supplieduserAccount.
-
Multitoken lacks validations maia-3 · 3 writeups critical
V12
Empty Deposits Mint Free Shares
src/2-audit/erc-4626/ERC4626MultiToken.sol:54sev: critical- Root cause
-
The deposit path does not validate
assetsAmounts.length == assets.length, andconvertToShares()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-suppliedassetsAmountsarray without requiring it to contain one entry for every configured asset.convertToShares()initializessharestotype(uint256).maxand then only lowers it while iterating overassetsAmounts.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:81sev: 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
ERC4626MultiTokensev: critical- Description
-
ERC4626MultiToken.convertToShares iterates user-supplied length, letting a depositor mint full-basket shares while depositing one asset.
convertToShares(line 184) andreceiveAssets(line 55) both loop over the user-controlledassetsAmounts.length; passing a length-1 array yieldsshares = X · totalWeights / weights[0]while onlyassets[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:237sev: critical- Root cause
-
redeemDeposit()and_redeemDeposit()do not delete the deposit entry or changeDepositStatus.Failedto 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 markedFailedand then calls_redeemDeposit()without consuming the deposit entry or changing its status._clearDeposit()marks the deposit asFailedwhen Anycall fallback reports the outbound message failed, and that status remains true after redemption._redeemDeposit()then mints back hTokens throughIPort.bridgeIn(), withdraws underlying tokens throughIPort.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 andBranchPort.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:237sev: 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:237sev: 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
BranchBridgeAgentsev: medium- Description
-
BranchBridgeAgent.redeemDepositlacks an ownership check for the failed deposit being redeemed. Any third party can force-finalize a victim'sFaileddeposit, removing the user's ability to callretrySettlement; because the deposit status is not transitioned away fromFailed, 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:786sev: medium- Root cause
-
sweepclearsaccumulatedFeesbefore 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 onlyminExecCostis unwrapped and deposited into Anycall. Thesweepfunction then setsaccumulatedFeesto 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:1111sev: 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:1111sev: 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
RootBridgeAgentsev: high- Description
-
RootBridgeAgent.sweep zeroes
accumulatedFeesbefore reading it for the transfer. Lines 1111-1115 setaccumulatedFees = 0and then passaccumulatedFees(now zero) tosafeTransferETH(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:57sev: low- Root cause
-
The array-shift loop in
removeAsset()usesi < assets.lengthwhile readingi + 1. It should stop atassets.length - 1and updatetotalWeightsbefore 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 whilei < assets.length, and each iteration readsassets[i + 1]andweights[i + 1]. On the final iteration,iequalsassets.length - 1, so the reads access indexassets.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:57sev: 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:57sev: 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
UlyssesTokensev: high- Description
-
UlyssesToken.removeAsset has an off-by-one shift and subtracts the wrong weight from
totalWeights. Lines 64-67 loopfor (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 69totalWeights -= weights[assetIndex]runs after the shift, subtracting the wrong (already-overwritten) weight.assetIdis 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:46sev: medium- Root cause
-
CoreBranchRoutersends request-type function ids through the system-response bridge path. The root router splits response ids and request ids acrossanyExecuteResponseandanyExecute, so0x01and0x04are 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
-
addGlobalTokenandsyncBridgeAgentbuild payloads with function ids0x01and0x04, but both submit them throughperformSystemCallOut.BranchBridgeAgent.performSystemCallOutwraps those payloads with bridge flag0x00, andRootBridgeAgent.anyExecuteroutes flag0x00only into the root router'sanyExecuteResponsehandler.CoreRootRouter.anyExecuteResponsehandles only0x02and0x03, while the0x01and0x04token-management actions are implemented in the separateanyExecutehandler. A valid branch request to add a global token or synchronize a bridge agent therefore reaches the wrong root-side dispatch table and returnsunknown selectorinstead of performing the requested state transition.
Pashov Skills
CoreRootRoutersev: medium- Description
-
CoreRootRouter.anyExecuteResponsedoes not handle all function IDs emitted by the branch router. It only handles0x02and0x03, while the matchingCoreBranchRoutersends0x01and0x04as 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:90sev: 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
-
CoreBranchRouteroverridesBaseBranchRouter.anyExecuteNoSettlementwithout preserving the inheritedrequiresBridgeAgentgate. The base implementation restricts inbound execution tolocalBridgeAgentAddress, but the override is plainexternal, 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-sidesetLocalTokenmapping 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:149sev: 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:90sev: 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
CoreBranchRoutersev: critical- Description
-
CoreBranchRouter.anyExecuteNoSettlement is unauthenticated; anyone can deploy hTokens and corrupt the root token registry. Line 149-153 declares
external virtual overridewith no caller check; payload(0x01, attackerAddr, …)reaches_receiveAddGlobalTokenwhich deploys an attacker-controlled hToken and emitsperformSystemCallOutwith funcId0x03, instructing the root chain to overwriteRootPort.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:37sev: high- Root cause
-
UlyssesFactoryinheritsOwnablewithout initializing ownership. Downstream pool logic then treatsfactory.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 tuneprotocolFeeon deployed pools, leaving a core economic parameter permanently fixed regardless of market or governance needs. - Description
-
UlyssesFactoryinherits SoladyOwnablebut does not define a constructor or initializer that calls_initializeOwner. In Solady,_initializeOwneris the routine that writes the owner slot, whiletransferOwnershipis guarded byonlyOwner; with a zero owner, no externally owned account can later transfer ownership. Pools created by the factory store the factory address and usefactory.owner()as the protocol-fee recipient and as the only address allowed to changeprotocolFee. As a result, every pool deployed through this factory has an unusable protocol-fee admin path, and any claimed protocol fees are transferred toaddress(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:601sev: critical- Root cause
-
_clearSettlementmutates a memorySettlementand 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
-
_clearSettlementloads the settlement into memory, checks that the stored status isPending, and then changes only the memory copy toSuccess. The function writes the modifiedcallDataback to storage but never writes the updated status, so the settlement remainsPendingafter 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 callclearSettlementagain 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:601sev: 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
RootBridgeAgentsev: medium- Description
-
RootBridgeAgent._clearSettlementupdates the settlement status only on a memory copy. The function loadsSettlement memory settlement = ..., setssettlement.status = Success, and writes back onlygetSettlement[...].callData; the storage status remainsPending, allowing unboundedclearSettlementretries and repeated evaluation ofuserFeeInfo.
- 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:120sev: high- Root cause
-
replenishReservesuses the caller-supplied_amountfor debt reduction instead of the actual repayment amount requested fromIPortStrategy.withdraw. It also charges debt tomsg.senderwhile 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
-
replenishReservescalls the selected strategy for onlymin(_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,_reservesLackingreturns zero, so an approved strategy can callreplenishReserveswith its outstanding debt and make the port request a zero-token withdrawal. The same call then subtracts the full_amountfromgetPortStrategyTokenDebt[msg.sender][_token]andgetStrategyTokenDebt[_token], clearing debt even though no reserves were returned. Becausemanagetransfers 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:152sev: 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:153sev: 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
BranchPortsev: 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
_strategyviaIPortStrategy(_strategy).withdraw(...)but decrementsgetPortStrategyTokenDebt[msg.sender][_token] -= _amountat line 161. Any non-strategy caller underflow-reverts after the strategy already executedwithdraw, and even the legitimate strategy can deduct the uncapped_amountwhile onlymin(_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:319sev: 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 correspondingmilestoneAwardback to the authority or preserve any accounting path by which the grantee can later earn it. After deletion, withdrawal calculations cannot include that award becauseconfirmMilestone()can no longer add the deleted award intomilestoneAwardTotalormilestoneUnlockedTotal. 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)
voteOnMetavestAmendmentdoes not verify that_grantbelongs to the proposal's setsrc/MetaVesTController.sol:589sev: high- Root cause
-
voteOnMetavestAmendmentnever checks_grantmembership insets[_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()ismsg.senderand 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_grantis in_setName, that_grantwas 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
metavestControllersev: medium- Description
-
voteOnMetavestAmendmentdoes not verify that_grantis a member of_setName. The function authenticates the voter against_grant's grantee and reads_callerPowerfrom_grant, but never checks that_grantis actually a member ofsets[_setName]. A grantee with a grant in any set can use that grant's power to vote on a different set's proposal, pushingcurrentVotingPowerabove thetotalVotingPower(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:140sev: high- Root cause
-
voteOnMetavestAmendmentfails to bind the voter_grantto 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
totalVotingPowerby iterating onlysets[setName], so the denominator is meant to represent that set. The voting function then accepts a caller-supplied_grant, checks only thatBaseAllocation(_grant).grantee()equalsmsg.sender, and adds that_grant'sgetGoverningPower()to the proposal. It never verifies that_grantis 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 intotalVotingPower. Once the inflatedcurrentVotingPowersatisfies the ratio inconsentCheck, the authority can execute consent-gated parameter updates for grants in the target set.
Claude Code harness (Opus 4.7)
getPaymentAmountdivides twice whenpaymentDecimals < exerciseTokenDecimals, drastically underpricing exercise/repurchasesrc/TokenOptionAllocation.sol; src/RestrictedTokenAllocation.sol:116sev: critical- Root cause
-
The
elsebranch 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 whenpaymentDecimals < 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
_amountfrom 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
TokenOptionAllocationsev: critical- Description
-
TokenOptionAllocation.getPaymentAmountdivides twice whenpaymentDecimals < exerciseTokenDecimals, allowing free exercise. Theelsebranch divides by10**exerciseTokenDecimalsand then again by10**(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:116sev: 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 whenclaimRepurchasedTokens()is called. - Description
-
getPaymentAmount()is intended to return a repurchase payment in payment-token decimals, but whenpaymentToken.decimals()is lower than the allocation token's decimals it divides twice by token-decimal factors. The first division by10**repurchaseTokenDecimalsalready converts_amount * repurchasePriceinto payment-token units whenrepurchasePriceis denominated in payment-token decimals per whole restricted token. Theelsebranch then divides again by10**(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-countstokensWithdrawn, causing permanent loss of vested tokens for granteesrc/VestingAllocation.sol; src/TokenOptionAllocation.sol:79sev: critical- Root cause
-
Recovery formula adds
tokensWithdrawneven though withdrawn tokens already left the contract balance. - Impact
-
Grantees can permanently lose vested tokens to authority; if
tokensWithdrawnexceeds the unvested portion,safeTransferreverts andterminatebecomes DoS'd. - Description
-
terminate()calculates recovery with an extra+ tokensWithdrawn, over-transferring to authority by exactlytokensWithdrawnand 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
tokensWithdrawneven 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.
tokensWithdrawnshould 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 isvested - withdrawn, and the amount recoverable by authority is simplytotal - vested. AddingtokensWithdrawnrecovers 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 becausetokensToRecoverexceeds the contract balance, leaving the allocation unterminable.
Pashov Skills
VestingAllocationsev: critical- Description
-
VestingAllocation.terminateover-countstokensWithdrawn, stripping grantee or bricking termination.tokensToRecover = tokenStreamTotal + milestonesAllocation - getVestedTokenAmount() + tokensWithdrawnadds tokens that have already left the contract, so termination either reverts (when2·tokensWithdrawn > vested) or transfers more than the unvested remainder — takingtokensWithdrawnworth 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:270sev: high- Root cause
-
terminate()double-counts previously withdrawn option tokens by addingtokensWithdrawnto 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()incrementstokensWithdrawnafter allocation tokens leave the contract.TokenOptionAllocation.terminate()later calculatestokensToRecoveras the total funded allocation minus exercisable and exercised amounts, but then addstokensWithdrawnback 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 andsafeTransfer()reverts beforeterminatedis 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:243sev: high- Root cause
-
removeMilestonedeletes the milestone struct without transferring or tracking itsmilestoneAward. - 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, butremoveMilestone()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
BaseAllocationsev: high- Description
-
removeMilestonestrands prefundedmilestoneAwardtokens permanently.delete milestones[idx]zeroes the struct in place without returning the milestone's prefunded tokens (transferred from authority atcreateMetavest) to anyone. The deleted slot contributes 0 to subsequentterminaterecovery accounting, andconfirmMilestoneon 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:139sev: high- Root cause
-
Milestone completion has no later unlock path for awards with
unlockOnCompletion == false; the linear unlock schedule excludes milestone awards and onlymilestoneUnlockedTotalcan 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 tomilestoneAwardTotal, making it part of the vested side ofVestingAllocation.getVestedTokenAmount(). It only adds the same award tomilestoneUnlockedTotalwhenmilestone.unlockOnCompletionis true.VestingAllocation.getUnlockedTokenAmount()caps linear unlocking atallocation.tokenStreamTotaland then adds onlymilestoneUnlockedTotal, while the allocation struct definestokenStreamTotalas excluding each milestone award. A completed milestone configured withunlockOnCompletion == falsetherefore becomes vested but is never included in unlocked accounting, sogetAmountWithdrawable()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:140sev: high- Root cause
-
proposeMajorityMetavestAmendmentand the majority branch ofconsentCheckhash only the trailing calldata word, omitting the_grantargument 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.
proposeMajorityMetavestAmendmentstoreskeccak256(_callData[_callData.length - 32:]), and the majority branch ofconsentCheckcompares 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_grantaddress 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)
consentToMetavestAmendmentignores_inFavorargument — grantee can never withhold or revoke consentsrc/MetaVesTController.sol:185sev: critical- Root cause
-
Implementation ignores
_inFavorand always setsinFavor = true. - Impact
-
Any grantee call to
consentToMetavestAmendment— including a deliberatefalseto 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
_inFavorparameter.
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
_inFavorand 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_inFavorbut always storesinFavor = 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-weekAMENDMENT_TIME_LIMITis not applied to them.
Pashov Skills
metavestControllersev: critical- Description
-
consentToMetavestAmendmentignores_inFavor, making revocation impossible. The function unconditionally writesinFavor = trueregardless of the supplied_inFavorargument. The natspec explicitly markets this parameter as the grantee's revocation mechanism, but a grantee calling with_inFavor=falsestill 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:216sev: high- Root cause
-
BaseAllocation.removeMilestonedeletes completed milestones without reversing the cumulative milestone accounting created byconfirmMilestone. - 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.confirmMilestonemarks a milestone complete and increments the cumulativemilestoneAwardTotal, and optionallymilestoneUnlockedTotal, that derived contracts later use for vesting, unlocking, and repurchase calculations.BaseAllocation.removeMilestoneis 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 rejectsmilestone.complete == trueand 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:580sev: high- Root cause
-
proposeMajorityMetavestAmendmentandconsentCheckhash_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 clearfunctionToSetMajorityProposal. - 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
metavestControllersev: 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 likeupdateMetavestUnlockRate(address _grant, uint160 _rate)the_grantargument 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:27sev: 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_LIMITas a one-week limit for amendments, and voting enforces that limit through_checkFunctionToTokenToAmendmentTime. The execution-sideconsentCheckfor majority proposals never checksproposal.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 clearsfunctionToSetMajorityProposal. The re-proposal guard is also inverted: it reverts whenisPending && 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)
terminateof a milestone whose status is alreadycompletecorrupts accountingsrc/BaseAllocation.sol:243sev: medium- Root cause
-
removeMilestonedoes not reject completed milestones or decrementmilestoneAwardTotal/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, butremoveMilestone()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:140sev: high- Root cause
-
consentToMetavestAmendmentdoes not persist the_inFavorargument 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
_inFavorboolean 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 writesfunctionToGranteeToAmendmentPending[_msgSig][_grant].inFavor = true. The event still emits the user-supplied_inFavorvalue, so off-chain observers can see a rejection while on-chain state records approval. The individual branch ofconsentChecklater relies directly onproposal.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
proposeMajorityMetavestAmendmentallows reusing an unexpired-pending slotsrc/MetaVesTController.sol:568sev: medium- Root cause
-
Condition uses
block.timestamp > proposal.timeinstead of checking againstproposal.time + AMENDMENT_TIME_LIMITfor 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
metavestControllersev: high- Description
-
proposeMajorityMetavestAmendmenttime check is inverted, permanently locking a (sig, set) pair after the first proposal. The guardif (isPending && block.timestamp > proposal.time) reverttriggers 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:140sev: high- Root cause
-
voteOnMetavestAmendmentfails to bind the voter_grantto 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
totalVotingPowerby iterating onlysets[setName], so the denominator is meant to represent that set. The voting function then accepts a caller-supplied_grant, checks only thatBaseAllocation(_grant).grantee()equalsmsg.sender, and adds that_grant'sgetGoverningPower()to the proposal. It never verifies that_grantis 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 intotalVotingPower. Once the inflatedcurrentVotingPowersatisfies the ratio inconsentCheck, 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:571sev: 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
metavestControllersev: medium- Description
-
Majority amendment voting compares a snapshotted total against live per-voter power.
totalVotingPoweris recorded once when the proposal is created across the set members, but each voter contribution is later read live throughgetGoverningPower(). BecauseconfirmMilestonecan 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:98sev: high- Root cause
-
repurchaseTokens()omits the deadline check for theshortStopDateset 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()recordsshortStopDate = block.timestamp + shortStopDurationfor the restricted-token repurchase window, butrepurchaseTokens()never checks eithershortStopDateorblock.timestamp. The authority can therefore callrepurchaseTokens()at any time after termination, even after the contractual short-stop window has expired. This contradicts the file's own state model whereshortStopDurationandshortStopDateexist 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.shortStopDateis never enforced; repurchase window is effectively infinitesrc/RestrictedTokenAllocation.sol:134sev: high- Root cause
-
repurchaseTokensdoes not referenceshortStopDate. - 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 theshortStopDateset 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 checksblock.timestamp <= shortStopDate.
-
Accumulation of vested or unlocked tokens metavest-12 · 3 writeups medium
V12
Rate changes rewrite accrual history
src/BaseAllocation.sol:193sev: high- Root cause
-
Mutable rate updates in
BaseAllocationdo not checkpoint historical accrual before changingallocation.vestingRateorallocation.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.updateVestingRateandBaseAllocation.updateUnlockRateoverwrite 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 currentallocation.vestingRateorallocation.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.withdrawtrusts the derivedgetAmountWithdrawable()value, so the corrupted cumulative accounting directly controls token withdrawals.
Claude Code harness (Opus 4.7)
Retroactive unlock/vesting rate changes can underflow
getAmountWithdrawableand freeze granteesrc/VestingAllocation.sol; src/RestrictedTokenAllocation.sol:95sev: high- Root cause
-
getUnlockedTokenAmountand analogous vesting math recalculate from current rate from scratch instead of snapshotting accrued amounts at rate changes. - Impact
-
getAmountWithdrawablecan 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:103sev: critical- Root cause
-
RefundGastreats a wei-denominated refund amount as ansdk.Coinamount in the native denom. The refund path omits theevm.WeiToNativeconversion thatVerifyFeeuses 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
-
VerifyFeecharges transaction gas in the native EVM denom by converting the transaction’s effective wei fee throughevm.WeiToNativebefore returningsdk.Coins. After execution,ApplyEvmTxcomputes unused gas and callsRefundGaswith that unused amount.RefundGasmultiplies unused gas bymsg.GasPrice(), which is a wei-denominated Ethereum gas price, but then constructssdk.NewCoin(denom, sdkmath.NewIntFromBigInt(remaining))directly without converting wei back to native units. Because the code defines1 unibi == 10^12 wei, every refunded wei amount is paid as native denom units, over-refunding unused gas by a factor of10^12relative 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
StateDBand execute SDK keeper operations against that context. Those SDK writes are outside the EVMStateDBjournal. The EVM journal only snapshots and revertsStateDBentries withSnapshot/RevertToSnapshot; it does not snapshot the Cosmos SDK multistore.ApplyEvmMsglater commits only the dirtyStateDBaccount/storage objects whencommitis 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:241sev: high- Root cause
-
EstimateGasForEvmCallTypeandcomputeCommitGasLimitreuse the full EVM/precompile execution path for simulations without charging a requester or reconciling custom precompile native work. The binary-search estimator repeatsApplyEvmMsg(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
-
computeCommitGasLimitruns before committed internal ERC20 helper execution and callsEstimateGasForEvmCallTypeon a cached context. The public estimator follows the same implementation: it validates only request shape, deriveshifrom request gas fields or gas cap, and binary-searches by repeatedly invoking anexecutableclosure. Each executable simulation rebuilds the EVM message and callsApplyEvmMsgwithcommit=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.bankSendinvokes ERC20Transfer/Burn;CallContractWithInputcreates a new geth message with an independent gas limit fromcomputeCommitGasLimit/estimation. InnerApplyEvmMsgruns within that limit and its gas is not deducted from the caller'sleftoverGas. The precompile itself charges only a flat or small per-byteRequiredGas. - 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:IsPastLimitandIsOutOfGasalways returnfalse, andGasRemainingreturnsmath.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 constantTxGas * 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:512sev: high- Root cause
-
convertCoinNativeERC20performs ERC20balanceOfandtransferhelper calls through internal EVM execution but does not charge or reconcile theirGasUsedin 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
balanceOfortransferlogic 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,
ConvertCoinToEvmroutes toconvertCoinNativeERC20. That settlement path reads the recipient balance, escrows bank coins, reads the module's ERC20 balance, executes an ERC20transferfromEVM_MODULE_ADDRESSto the recipient, and reads the recipient balance again before burning bank coins. EachbalanceOfandtransfergoes 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 abankSendcaller-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:69sev: high- Root cause
-
The Wasm precompile has no reconciliation layer between CosmWasm execution gas and EVM gas.
RequiredGasis 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.RequiredGasdelegates 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 CosmWasmExecute,Instantiate, and repeatedExecutecalls directly from the precompile, so the amount of Wasm VM work is not measured and deducted from EVM gas.ApplyEvmTxlater refunds the sender frommsg.Gas() - evmResp.GasUsedand 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.executeMultiamplifies 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:IsPastLimitandIsOutOfGasalways returnfalse, andGasRemainingreturnsmath.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 constantTxGas * 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:56sev: critical- Root cause
-
bankSendexecutes ERC20 and bank keeper side effects against the SDK context obtained fromStateDB.GetContextinstead of a context/journal scoped to the enclosing EVM call frame. Nested ERC20 helpers are explicitly committed withcommit=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.ContextfromStateDB.GetContextand passes it intobankSend. InsidebankSend, the precompile first calls the ERC20Transferhelper, then either calls the ERC20Burnhelper 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 ERC20TransferandBurnhelpers execute internal EVM messages withcommit=true, causing their internal StateDB writes to commit independently of the caller's outer EVM frame. The outer EVM snapshot/revert mechanism inStateDBreplays only its own journal and commits only dirty StateDB accounts/code/storage, so a contract can callbankSendand 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
StateDBand execute SDK keeper operations against that context. Those SDK writes are outside the EVMStateDBjournal. The EVM journal only snapshots and revertsStateDBentries withSnapshot/RevertToSnapshot; it does not snapshot the Cosmos SDK multistore.ApplyEvmMsglater commits only the dirtyStateDBaccount/storage objects whencommitis 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)
RequiredGasfor 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:73sev: critical- Root cause
-
bankSendignores the success boolean returned byERC20().Transfer. The bridge assumes that absence of an EVM revert means ERC-20 escrow succeeded, but ERC-20 permitstransferto returnfalsewithout 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().Transferexplicitly decodes and returns the ERC-20transferboolean, allowing callers to distinguish a successful transfer from a standards-compliantfalsereturn. 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 whosetransferreturnsfalseinstead of reverting on failure,bankSendcontinues as if escrow succeeded. On mappings created from an ERC-20, the same function then mintsfuntoken.BankDenombank 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
transferreturn value inFunToken.bankSendenables free minting of bank coinssev: critical
- Root cause
-
erc20Calls.Transferreturns(bool, error). The boolean reflects the actual return value ofERC20.transfer(...). InprecompileFunToken.bankSend, the boolean is discarded (_, err = p.evmKeeper.ERC20().Transfer(...)) and onlyerris checked. For non-standard ERC20 contracts that signal failure by returningfalsewithout 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 returnsfalsefromtransfercan repeatedly callbankSendwithout losing ERC20 tokens while the precompile mintserc20/<address>bank coins to any Nibiru address. This enables unbounded minting at near-zero cost; the coins are tracked byx/bank, participate in total supply accounting, and can be sent through IBC, traded, or otherwise abused. The same pattern exists forIsMadeFromCoin == truebut is practically limited because the trusted module ERC20 reverts on failure. - Description
-
precompileFunToken.bankSenddiscards the boolean returned byerc20Calls.Transferand checks onlyerr, so ERC20 transfers that returnfalsewithout reverting are treated as successful and bank coins are minted/sent.
Codex harness (GPT-5.5)
bankSendmints bank coins even whenERC20.transferreturnsfalsesev: high
- Root cause
-
bankSendignores the boolean success value returned byERC20.transferand treats a non-reverting false return as success. - Impact
-
A mapped ERC20 that returns
falseinstead of reverting can mint unbacked bank coins through the FunToken precompile. This is exploitable against valuable/non-standard ERC20s that signal transfer failure withfalse, and lets arbitrary registered ERC20 contracts mint their mapped bank denomination without backing. - Description
-
erc20Calls.Transfercorrectly unpacks and returns the boolean value fromERC20.transfer. The FunToken precompile discards that boolean and checks onlyerr. If the ERC20 call returnsfalsewithout reverting,erris nil andbankSendcontinues. For FunToken mappings created from an ERC20 (IsMadeFromCoin == false), the precompile then mintsfuntoken.BankDenombank 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:221sev: high- Root cause
-
Reward-token registration validates only address nonzero and uniqueness, while epoch rollover unconditionally trusts every registered address to answer
balanceOfforever 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
distributerevert. - Description
-
registerNewRewardTokenpermanently appends any nonzero address torewardTokenListwithout proving that the address is an ERC20-compatible reward token.distributelater iterates every registered token and performs a high-levelIERC20(_token).balanceOf(address(this))call before it can start the next epoch. A registered EOA, non-token contract, or token whosebalanceOfreverts 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 endedstakeandunstakeboth 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:221sev: 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
balanceOfor transfer behavior reverts can freezedistribute()orclaim()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:56sev: high- Root cause
-
registerNewRewardTokendoes not forbidnewRewardToken == pdt, andwithdrawRewardTokenshas no accounting guard that separates registered reward balances from staked principal. - Impact
-
A
TOKEN_MANAGERaccount can withdraw PDT backing active stPDT balances, leaving users with receipt tokens that can no longer be redeemed throughunstake. The loss can cover the entire staked principal balance held by the contract. - Description
-
StakedPDTstores users' staked PDT in the contract and mints stPDT receipts one-to-one whenstakepullspdtfrom the caller.registerNewRewardTokenaccepts any nonzero address that is not already inrewardTokenList, but it does not reject the immutablepdttoken. Oncepdtis registered as a reward token,withdrawRewardTokenstreats it like any other registered reward token and transfers an arbitraryamountto theTOKEN_MANAGERcaller. 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)
pdtcan be registered as a reward token, enabling theft of staked principalsrc/contracts/StakedPDT.sol:221sev: high- Root cause
-
registerNewRewardTokenchecks only for zero address and duplicates; it does not checknewRewardToken != 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_MANAGERcan register PDT as a reward token and withdraw all staked principalsrc/contracts/StakedPDT.sol:221sev: high- Root cause
-
registerNewRewardToken()does not reject the staking token address, andwithdrawRewardTokens()does not check whether the registered token is the staking asset or whether the withdrawal would remove user principal. - Impact
-
A
TOKEN_MANAGERcan drain the staked PDT principal backing all outstandingstPDT, causing complete loss of staked funds and makingunstake()unable to return user principal. - Description
-
registerNewRewardToken()accepts any nonzero token address that has not already been registered.withdrawRewardTokens()then allowsTOKEN_MANAGERto 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, aTOKEN_MANAGERcan registerpdtitself as a reward token and then withdraw the PDT balance held by the staking contract. That PDT balance is the collateral backing all outstandingstPDTreceipts minted bystake(). Exploit scenario:1. Users stake PDT and receive
stPDT. The underlying PDT accumulates inStakedPDT.2.
TOKEN_MANAGERcallsregisterNewRewardToken(pdt).3.
TOKEN_MANAGERcallswithdrawRewardTokens(pdt, IERC20(pdt).balanceOf(address(this))).4. The underlying PDT principal is transferred to the manager. Users still hold
stPDT, butunstake()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
StakedPDTsev: medium- Description
-
PDT itself can be registered as a reward token, making staked principal claimable as rewards.
registerNewRewardTokenonly rejectsaddress(0)and duplicate reward tokens, and does not preventnewRewardTokenfrom being the PDT staking token. Once PDT is registered,distribute()readsIERC20(pdt).balanceOf(this), including the entire staked principal, subtractsunclaimedRewards[pdt] = 0, and allows the balance to be paid out throughclaim'ssafeTransfer. This can drain principal from the staking contract and cause legitimateunstakecalls 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:221sev: high- Root cause
-
The initial-share path in
Checkpoint.toSharesGlobal()ignores nonzero checkpoint assets whenself.sharesis 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 fromasset.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 wheneverself.sharesis 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
Vaultpackages/perennial-vault/contracts/Vault.sol:238sev: 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
Vaultsev: high- Description
-
First-depositor share inflation via donation absorbed by
Checkpoint.initialize._checkpoint(Vault.sol:228) callscurrentCheckpoint.initialize(context.global, asset.balanceOf()), andCheckpoint.initializesetsself.assets = balance − (global.deposit + global.assets). Any direct token transfer to the vault between checkpoints is captured into the next checkpoint'sassets. Attack: attacker deposits the minimumsettlementFee(ε) as first depositor, oracle ticks, attacker holdsεshares; attacker donatesD >> εraw tokens to the vault; victim depositsX < Dand at the next checkpoint receivesX·muldiv(ε, ε+D)shares — which rounds to0; attacker redeemsεshares forε + D + X, profitingX. 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:192sev: critical- Root cause
-
signature::verify_messagetreatsmessage_offsetas a binding between the current instruction bytes verified by the ed25519 program and the separatemessage_dataargument, but it never validates that binding. The offset checks are algebraic against attacker-controlled input, while signer and payload extraction are performed frommessage_datarather than from the exact byte range verified by ed25519. - Impact
-
An attacker can fabricate a
VerifiedMessagewhosepublic_keyis any currently trusted signer and whosepayloadis 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_messageexposes bothmessage_dataandmessage_offsetas caller-controlled instruction arguments and forwards them unchanged intosignature::verify_message. The verifier checks only that the prior ed25519 instruction descriptor uses offsets equal tomessage_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 separatemessage_dataslice at positions derived by subtracting the same caller-suppliedmessage_offset, which collapses back to fixed offsets insidemessage_data. Because it never proves thatmessage_offsetis the actual byte offset of thismessage_dataargument 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:160sev: critical- Root cause
-
verify_messagereceives amessage_data: Vec<u8>argument and readsmagic,public_key,message_size, andpayloadfrom fixed offsets insidemessage_data, while only checking that the offsets fed to the ed25519 program are consistent with the user-providedmessage_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 frommessage_data. - Impact
-
The return value of
verify_messageis 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
VerifiedMessagefrom 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_messageis supposed to ensure that thepayloadit returns was signed by a trusted signer, but the contract readspublic_key/payloadfrom the borsh-deserializedVec<u8>argument while the ed25519 program reads them frominstruction_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:192sev: critical- Root cause
-
verify_messageacceptsmessage_offsetfrom the caller and uses it only to validate the offsets contained in the prior Ed25519 instruction. It never checks thatmessage_offsetis the actual offset of the Anchormessage_dataargument 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_messageaccepts a caller-controlledmessage_offset, allowing the Ed25519 precompile to verify bytes at one position in the current instruction while the receiver authenticates and returns different bytes from themessage_dataargument.
-
-
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:467sev: critical- Root cause
-
process_transferomits 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 sourceavailable_balanceequals 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_transferaccepts 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, unlikeprocess_withdraw, which subtracts the plaintext amount and rejects the operation unless the resultingavailable_balanceequals the proof’sfinal_ciphertext. In both fee and no-fee transfer paths,process_source_for_transfersubtracts 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:470sev: 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_dataand 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 withXwhile corrupting the source account, apply the destination pending balance, and withdrawXto 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_balanceciphertext, but the processor never verifies that the claimed ciphertext is the same as the source account's currentConfidentialTransferAccount::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)
EmptyAccountdoes not bind theCloseAccountproof to the account'sencryption_pubkey, and does not checkwithheld_amountbefore zeroingavailable_balancetoken/program-2022/src/extension/confidential_transfer/processor.rs:206sev: 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
EmptyAccountuntil harvest/withdraw clears it, an implicit and undocumented burden. Although failed transactions roll back, the ordering makes static reasoning brittle. - Description
-
process_empty_accountchecks that the account'savailable_balanceciphertext matches the proof ciphertext but does not compare the proof's ElGamal pubkey to the account'sencryption_pubkey. It also zeroesavailable_balancebefore callingclosable()and does not pre-checkwithheld_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:142sev: high- Root cause
-
internal_unstakedoes not special-case reward-backed withdrawals whereactual_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_REWARDSafter it reducesactual_amount_to_unstaketo 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_unstakebecomes zero whileexcess_unstaked_amountis accepted against rewards. The function then creates a claim for the user’s fullassets_to_unstake, updates reward accounting, burns shares, and still appends aStakingMsg::Undelegatewhoseamountis 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:1671sev: medium- Root cause
-
In the excess-withdrawal branch,
actual_amount_to_unstakeis set tovalidator_total_stakedwhen the withdrawal exceeds the validator stake. Ifvalidator_total_stakedis zero andCONTRACT_REWARDSis sufficient, the contract reducesCONTRACT_REWARDSand creates a claim, but still emitsStakingMsg::Undelegatewith 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_REWARDSholds 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_unstakealways 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 lotssrc/OptionSettlementEngine.sol:303sev: 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 suppliedclaimIdbelongs 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 thoughoptionIdis 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 becauseredeem()rejects token IDs whose decodedclaimNumis zero.
-
Rounding error in the redeem mechanism valorem-2 · 4 writeups high
V12
Bucket Rounding Burns Claims
src/OptionSettlementEngine.sol:590sev: critical- Root cause
-
_getAmountExercised()rounds both the exercised and unexercised portions down independently, andredeem()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
underlyingAmountandexerciseAmountvalues. - Description
-
Claim redemption computes a claim's exercised and unexercised option counts with two independent
mulDivDownoperations. 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 offeeBalance, so the burned claim has no recovery path.
Claude Code harness (Opus 4.7)
Rounding loss in
_getAmountExercisedpermanently locks dust per bucketOptionSettlementEngine.sol:585sev: 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/exerciseAmountfactors, the stuck amounts can become economically meaningful and may underfund claimants. - Description
-
The per-claim allocation uses
mulDivDowntwice, 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 thanbucket.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:592sev: 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, sosweepFees()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 thanclaimIndex.amountWritten. The missing option units are never assigned to any claim and there is no later reconciliation path.
Pashov Skills
OptionSettlementEnginesev: medium- Description
-
Round-to-zero in
_getAmountExercisedcan wipe small writers out of a bucket. Both legs at L592-602 use independentmulDivDown, so for a claim with very smallclaimIndex.amountWrittenrelative tobucket.amountWrittenboth_exercisedand_unexercisedround 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:293sev: 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
-
writepermits new option lots until expiry and places every write from the same UTC day into the current bucket, even if that bucket already has nonzeroamountExercised. When a same-day bucket has been partially exercised,_addOrUpdateClaimBucketincreases onlyamountWritten, leaving the prioramountExercisedattached to the enlarged bucket._addOrUpdateClaimIndexthen records the late writer's claim in that same bucket, so_getAmountExercisedtreats 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:552sev: high- Root cause
-
New writes are merged into an already-exercised same-day bucket, and redemption uses the bucket's final
amountWrittenandamountExercisedrather 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()incrementsclaimBucketInfo.amountExercisedwhen options are exercised. Later,_addOrUpdateClaimBucket()will still merge new writes into the current day bucket even whenamountExercised > 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
OptionSettlementEnginesev: critical- Description
-
Same-day write into an exercised bucket steals exercise proceeds.
_addOrUpdateClaimBucket(L640-651) unconditionally addsamounttocurrentBucket.amountWrittenwhen the last bucket is today's, even afteramountExercised > 0; the pro-rata redemption math in_getAmountExercisedthen 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 receivesmulDivDown(20, 5, 25) = 4exercise 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:14sev: critical- Root cause
-
pullToken()exposes a genericsafeTransferFrom()primitive without bindingfromtomsg.senderor 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, thefromaddress, therecipient, and theamount, then executestoken.safeTransferFrom(from, recipient, amount).PaymentsFacetinherits this helper, and the deployment script addsPaymentsFacetas 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 whetherfromhas 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/PeripheryPaymentsexpose the Voyage diamond's funds and any third-party token allowance to anybody (drain primitive)contracts/shared/facets/PaymentsFacet.sol:15sev: 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
DiamondVersionFacetsev: critical- Description
-
PeripheryPayments.pullTokenis publicly callable and lacks access control while accepting arbitraryfromandrecipientarguments. Any actor can call the diamond with a victim address that has approved it and an attacker-controlled recipient, causing the diamond to executesafeTransferFrom(victim, attacker, amount). This drains allowances granted by LPs or borrowers for flows such asLiquidityFacet.depositandLoanFacet.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:115sev: critical- Root cause
-
buyNowtrusts 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
-
buyNowrecords the caller-supplied_collectionand_tokenIdas collateral, but the marketplace calldata is never bound to those values. The adapters extract a price and validate only broad order shape, whileLibLoan.initDebtimmediately marksparam.collectionandparam.tokenIdas the lien.MarketplaceAdapterFacet.purchasethen 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_datathat buys a different NFT into the vault. BecauseVaultFacet.withdrawNFTonly blocks tokens whose exactnftIndexentry 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.extractAssetPricereturns the **taker** order price, which is user-suppliedcontracts/voyage/adapter/LooksRareAdapter.sol:89sev: 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
LoanFacetsev: medium- Description
-
buyNowdoes not bind the marketplace order to the named(_collection, _tokenId). The user-supplied_collectionand_tokenIdare used to size the loan viaextractAssetPriceand to register the lien, but the opaque_datablob is forwarded toMarketplaceAdapterFacet.purchasewithout 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:148sev: 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()exposesVToken.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 withpushWithdraw().pushWithdraw()converts the burned shares back to assets after total supply has already been reduced, while tranchetotalAssets()still includes the pool’s underlying balance, so the recordedmaxUnderlyingcan exceed the amount the user requested to withdraw. Becauseclaim()has no cooldown check and transfers up to that inflatedmaxUnderlying, 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 fromERC4626) bypasses the cooldown / unbonding mechanism entirelycontracts/voyage/tokenization/VToken.solsev: 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:70sev: high- Root cause
-
totalUnbondingis tracked in share units but is consumed bytotalAssets()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
-
VTokenstorestotalUnbondingby adding the number of burned shares inpushWithdraw(). The concrete tranche implementations subtracttotalUnbondingdirectly from ERC20 asset balances intotalAssets(), even though the variable is not denominated in assets.VTokeneven definestotalUnbondingAsset()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-4626totalAssets()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:7sev: 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:46sev: 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:41sev: critical- Root cause
-
pushWithdraw()derives the claim amount fromconvertToAssets(_shares)after_burn()has changed the ERC-4626 exchange rate. The code should record the requested_amountor 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 callspushWithdraw().pushWithdraw()records the pending claim by converting the burned shares back to assets usingconvertToAssets()after the burn has reducedtotalSupply(). BecauseconvertToAssets()divides by the current supply, the post-burn conversion inflates the user'smaxUnderlyingabove the_amountthey requested whenever less than the full supply is burned.claim()then pays the inflatedmaxUnderlyingwhenever 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 records400 * 1000 / 600 = 666assets 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:208sev: 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
-
buyNowrequires the borrower to provideparams.downpayment, which is the first PMT and includes both principal and interest. The function then borrows onlyoutstandingPrincipal, unwraps WETH throughPaymentsFacet.unwrapWETH9, and transfers exactlyparams.totalPrincipalETH to the vault for the purchase. Sincedownpayment + outstandingPrincipalequals the purchase principal plus the first-period interest, the first-period interest remains as ETH on the diamond after the purchase funding step.LibLoan.distributeInterestseparately 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 publicrefundETHhelper to receive the diamond's entire ETH balance.
Claude Code harness (Opus 4.7)
LoanFacet.buyNowsendstotalPrincipalto the vault but only credits the senior pool withoutstandingPrincipalcontracts/voyage/facets/LoanFacet.sol:250sev: 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:150sev: 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
-
buyNowandliquidateboth read a TWAP value and timestamp from the configuredPriceOracle, 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.updateTwapis 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)
liquidatedoes not enforceparam.liquidationBonus > 0/ does not validate floor-price freshnesscontracts/voyage/facets/LoanFacet.sol:397sev: 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
LoanFacetsev: medium- Description
-
buyNowdoes not check TWAP timestamp staleness.(params.fv, params.timestamp) = priceOracle.getTwap(params.collection)records the TWAP timestamp but only checksfv != 0. Per the protocol's threat model, oracle operators canupdateTwappermissionlessly; 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:24sev: high- Root cause
-
The delayed-withdrawal state machine is applied only to
withdraw()and is not enforced inredeem()orclaim(). The contract declarescooldownbut 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
-
VTokendefines acooldownand overrideswithdraw()to enqueue burned shares inunbondingsinstead of transferring assets immediately. The contract does not override ERC-4626redeem(), so the inherited publicredeem()path remains available and burns shares before immediately transferring assets to the receiver. The queued path also has no timestamp inUnbondingandclaim()checks only the current token balance, notcooldown. Any share holder can therefore exit throughredeem()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 fromERC4626) bypasses the cooldown / unbonding mechanism entirelycontracts/voyage/tokenization/VToken.solsev: 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:125sev: medium- Root cause
-
The shortfall calculation in
Vault.refundGas()subtracts the available WETH from the ETH balance instead of setting the refundable amount toethBal + 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 loweringamountRefundableinstead of reverting. The shortfall branch computesamountRefundable = amountRefundable - toUnwrap - balanceWETH9, wheretoUnwrapis already_amount - ethBal. Algebraically this becomesethBal - 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, butpostRelayedCall()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.refundGasmis-computes the refund when WETH is insufficient (always reverts in the "edge" path)contracts/vault/Vault.sol:125sev: 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:89sev: critical- Root cause
-
postRelayedCallomits the inheritedrelayHubOnlyaccess-control modifier while calling the highly privilegedVault.refundGaspath. 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.postRelayedCallis an external function with norelayHubOnlyrestriction, even though it performs the state-changing refund step. Any caller can supply an ABI-encoded vault address ascontext, choose arbitrarygasUseWithoutPostandrelayData.gasPrice, and force the paymaster to callIVault(vault).refundGas(refund, treasury). The vault accepts this call becauserefundGasonly checks thatmsg.senderis the configured paymaster, which is true when the call is made fromVoyagePaymaster. The vault then sends ETH, and unwraps WETH when ETH is insufficient, to the immutabletreasuryaddress. 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:49sev: medium- Root cause
-
getUpgrade()uses the wrong scratch array when collecting current diamond selectors. Current selectors must be appended toexistingSelectors; 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 inexistingSelectorFacetMap, but it pushes those selectors intonewSelectorsand never pushes anything intoexistingSelectors. The removal phase iteratesexistingSelectors.length, so that loop is always empty for a clean caller state and noFacetCutAction.Removeentries 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:15sev: 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
-
PaymentsFacetexposes 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 arbitraryrecipient, whilesweepToken()transfers the diamond’s entire balance of any caller-selected ERC20 to an arbitraryrecipient.refundETH()sends the diamond’s entire native ETH balance tomsg.sender, and the diamond has an unrestricted payablereceive()function that can accumulate ETH. Because these functions have noauthorised, 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/PeripheryPaymentsexpose the Voyage diamond's funds and any third-party token allowance to anybody (drain primitive)contracts/shared/facets/PaymentsFacet.sol:15sev: 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
PaymentsFacetsev: medium- Description
-
Permissionless
wrapWETH9enables ETH-to-WETH conversion before draining.wrapWETH9ispublic payablewith no auth and wrapsaddress(this).balance. While not directly stealing on its own, it composes with the unauthenticatedunwrapWETH9/sweepTokento 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)
Multicallallowsmsg.valueto be re-used across iterations (free-value bug)contracts/shared/util/Multicall.solsev: 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.buyNowis not protected against re-entrancy from the marketplace purchase or NFT receive hookcontracts/voyage/facets/LoanFacet.sol:115sev: 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:47sev: critical- Root cause
-
The hook authenticates
ZetaSentby ABI decoding alone and never binds the event to the trusted connector address.ProcessZetaSentEventperforms 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
-
PostTxProcessingscans every EVM receipt log and treats any log that ABI-decodes asZetaSentas an authoritative withdrawal request. The parser binds tolog.Addressand only checks the event signature and layout;ProcessZetaSentEventnever verifies thatevent.Raw.Addressis the deployedZetaConnectorZEVMor the system contract’s configured connector address. A malicious ZEVM contract can emit a lookalikeZetaSentevent without executingZetaConnectorZEVM.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 aPendingOutboundCCTX 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)
ProcessZetaSentEventdoes not validate the emitting contract — anyone can drain ZETA from thefungiblemodule account and trigger fake cross-chain ZETA withdrawalsx/crosschain/keeper/evm_hooks.go:38sev: critical- Root cause
-
PostTxProcessingparses every receipt log as a possibleZetaSentevent, but unlike ZRC20 withdrawals,ProcessZetaSentEventdoes 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
fungiblemodule account and force TSS-signed ZETA withdrawals to attacker-controlled addresses on supported destination chains. - Description
-
ProcessZetaSentEventperforms no validation ofevent.Raw.Address, so any ZEVM contract can emit a fakeZetaSentlog with attacker-controlled destination and value parameters.
Codex harness (GPT-5.5)
Any ZEVM contract can forge
ZetaSentlogs and force outbound TSS worksev: high
- Root cause
-
The hook iterates all logs in every EVM receipt and parses any log with the
ZetaSentABI, constructing a filterer for the log's own address. It does not verify thatlog.Addressequals 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
zetaValueAndGasup 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
ZetaSentevents 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:171sev: 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
-
AddToOutTxTrackerappends every uniquemsg.TxHashto an existing tracker'sHashListand does not validate hash format, require one hash per signer, cap the list, or verify that the hash corresponds to the pending outbound nonce.RemoveFromOutTxTrackeralso 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 iteratestracker.HashListin 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/RemoveFromOutTxTrackeraccept a single validator's word — outbound observation can be DoS'd or poisonedx/crosschain/keeper/keeper_out_tx_tracker.go:158sev: high- Root cause
-
The tracker add/remove functions require only
IsBondedValidatororAdminKey; 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
OutTxTrackersev: high
- Root cause
-
MsgAddToOutTxTrackeris 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 thattxHashis 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
OutboundMinedeven 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:46sev: high- Root cause
-
The shared
submittedTxmap is treated as cross-goroutine state but reads and writes are not protected byob.muor replaced withsync.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
-
BitcoinChainClientstores outbound transaction observations in the plain Go mapsubmittedTx, but the map is accessed from multiple goroutines without using the client mutex.StartlaunchesobserveOutTxas a background goroutine, and that goroutine writesob.submittedTx[outTxID] = *getTxResultfor every observed tracker hash. Separately, outbound scheduling callsIsSendOutTxProcessed, 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 fatalconcurrent map read and map writepanics 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:73sev: 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 unusedconfCountfield makes the intended safety invariant explicit but it is initialized to zero and not checked inobserveInTx. - 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 = 0and the inbound watcher never checks the confirmation count of the block it is about to report. Each polling cycle processeslastBN + 1as soon asGetBlockCount()is greater thanlastBN, reports every parsed deposit throughPostSend, and then advanceslastBlockto that height. When the reported inbound vote finalizes on ZetaCore, a ZetaChain receiver path immediately callsHandleEVMDeposit, 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:73sev: high- Root cause
-
ob.confCount = 0is hardcoded,IsSendOutTxProcessedposts confirmation whenConfirmations > 0, andsubmittedTxis 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
confCountis 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 requiresres.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:13sev: high- Root cause
-
SetNodeKeystreats possession of any valid account key as sufficient authorization for node-account creation. The storedNodeAccountrecords 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
-
SetNodeKeysis an externally reachable crosschain message and is registered in both the legacy handler and gRPCMsgservice. The implementation only checks thatmsg.Creatoris a syntactically valid Cosmos account address, then creates aNodeAccountwith the caller-suppliedPubkeySetif 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 allNodeAccountAllresults 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)
SetNodeKeyshas no authorization — anyone can create node accounts and pollute keygenx/crosschain/keeper/keeper_node_account.go:103sev: high- Root cause
-
SetNodeKeysvalidates 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
SetNodeKeysand register an arbitraryNodeAccountwith 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:439sev: high- Root cause
-
observeInTXassumes every emitteddestinationChainIdmaps to a configured chain and dereferencesdestChainbefore validating it. The file lacks a defensive unsupported-chain branch before usingdestChain.ChainName. - Impact
-
An attacker can submit a connector
sendusing 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
-
observeInTXtrusts thedestinationChainIdemitted in externalZetaSentlogs and immediately dereferences the result ofcommon.GetChainFromChainIDwithout checking fornil. The connector ABI exposessendwith a caller-supplieddestinationChainIdfield, so aZetaSentlog can carry a chain ID that is not present in the local default chain catalog.common.GetChainFromChainIDreturnsnilfor unknown IDs, and the next line inobserveInTXreadsdestChain.ChainName.String(), which panics.ExternalChainWatcherdoes not recover panics aroundobserveInTX, 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 dereferencesdestChain.ChainNameand EVMChainConfigswithout 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
ZetaSentevent with an unsupporteddestinationChainId, or with a supported non-EVM destination such as Bitcoin that has noChainConfigsentry. 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:41sev: high- Root cause
-
Signer identity is not canonicalized before storage or duplicate checks.
CreateTSSVotercounts raw Bech32 strings inSignersinstead 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
-
CreateTSSVoterauthorizes the caller by decodingmsg.Creatorand comparing the resulting account bytes to bonded validator operator bytes, but it stores and de-duplicates signer identities as raw strings.MsgCreateTSSVoter.ValidateBasiconly requiresmsg.Creatorto decode as a Bech32 account address, and the duplicate check inisDuplicateSigneruses 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.CreateTSSVoterthen finalizes solely whenlen(tssVoter.Signers) == len(validators), so duplicate case variants count as distinct validators toward the full-consensus threshold and can write the finalizedTSSrecord.
-
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:221sev: 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.TryProcessOutTxslicesReceiver[2:]before installing its deferredEndTryProcess, 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 theoutTxIDpermanently active in the scheduler. The health monitor later counts stuck active entries and sendsSIGINTwhen 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:327sev: high- Root cause
-
CreateTSSVoteruses two different validator universes for the same quorum check. It authorizes only bonded validators but finalizes against the count of all validators returned byGetAllValidators. - 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
-
CreateTSSVoterloadsvalidatorswithGetAllValidators, then authorizes individual votes throughIsBondedValidator, which only accepts validators wherev.IsBonded()is true. The same unfilteredvalidatorsslice is later used as the denominator for finalization throughlen(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 byGetAllValidators, the number of possible signers is smaller thanlen(validators), so the TSS record cannot finalize even if every eligible bonded validator votes.
Claude Code harness (Opus 4.7)
CreateTSSVoterfinalization condition uses *all* validators (incl. unbonded) and a fragile block-bucket session IDx/crosschain/keeper/keeper_tss_voter.go:104sev: high- Root cause
-
GetAllValidatorsincludes bonded, unbonding, and unbonded validators, while finalization checkslen(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
GetAllValidatorsand 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.