TAPIOCA-BAR SECURITY AUDIT REPORT
Protocol: Tapioca-bar (BigBang CDP + Singularity Lending + USDO Stablecoin) Repository: https://github.com/sherlock-audit/2024-02-tapioca/tree/main/Tapioca-bar Files Audited: 39 Solidity files (~7,417 LOC) Methodology: Map-Hunt-Attack with 5 parallel agents (4 vector scanners + 1 adversarial opus model) Static Analysis: Manual only (Slither/Aderyn unavailable) Date: 2026-03-10
Table of Contents
Executive Summary
This audit of the Tapioca-bar protocol identified 25 vulnerabilities across 39 Solidity contracts comprising approximately 7,417 lines of code. The protocol implements a CDP lending system (BigBang) for minting the USDO stablecoin, a Kashi-fork lending market (Singularity), and cross-chain token functionality via LayerZero.
The most severe finding is a fundamental accounting bug where normal liquidations in both BigBang and Singularity never decrement totalBorrow, causing phantom debt to accumulate permanently. This leads to inflated interest rates, incorrect solvency calculations, and eventual protocol insolvency. Combined with broken leverage functions (wrong YieldBox asset IDs and token approvals), the protocol has critical operational failures that would affect users immediately upon deployment.
Finding Summary
| Severity | Count |
|---|---|
| CRITICAL | 3 |
| HIGH | 8 |
| MEDIUM | 10 |
| LOW | 4 |
| Total | 25 |
Scope
Files Audited
| Module | Files | LOC (approx) |
|---|---|---|
| BigBang (CDP Market) | BBStorage, BBCommon, BBLendingCommon, BBBorrow, BBCollateral, BBLeverage, BBLiquidation, BigBang | ~2,100 |
| Singularity (Lending) | SGLStorage, SGLCommon, SGLLendingCommon, SGLBorrow, SGLCollateral, SGLLeverage, SGLLiquidation, Singularity | ~2,000 |
| USDO (Stablecoin) | Usdo, BaseUsdo, BaseUsdoTokenMsgType, USDOFlashloanHelper, UsdoHelper, UsdoMsgCodec, RevertMsgDecoder, ModuleManager, UsdoMarketReceiverModule, UsdoOptionReceiverModule, UsdoReceiver, UsdoSender | ~1,800 |
| Core | Penrose, Market, MarketERC20, MarketHelper | ~900 |
| Leverage | BaseLeverageExecutor, SimpleLeverageExecutor, AssetToSGLPLeverageExecutor, AssetTotsDaiLeverageExecutor | ~400 |
| Other | Origins, ExampleMarketLiquidatorReceiver, SafeApprove | ~200 |
Exclusions
interfaces/, lib/, mocks/, test/, *.t.sol, node_modules/gitmodule/tapioca-periph/contracts/layerzero/gitmodule/tapioca-periph/contracts/deprecated/System Architecture
Component Overview
+-------------------+
| Penrose | Global registry, fee management, market factory
+--------+----------+
|
+----+----+-------------------+
| | |
+---v---+ +---v--------+ +-----v------+
|BigBang| |Singularity | | USDO |
| (CDP) | |(Lending Mkt)| |(Stablecoin)|
+---+---+ +------+------+ +-----+------+
| | |
+-----+------+ +------+------+
| | |
+-----v------+ +----v----+ +-----v--------+
| YieldBox | |Leverage | |LayerZero OFT |
| (Vault) | |Executors| |(Cross-chain) |
+------------+ +---------+ +--------------+
Key Trust Assumptions
delegatecall are trusted (BigBang, Singularity, USDO)leverageExecutor and liquidatorReceiver are semi-trusted (whitelisted via Cluster)Core Invariants
totalBorrow.base == sum(userBorrowPart[all users])totalCollateralShare == sum(userCollateralShare[all users])totalAsset.elastic backs all lender fractions (totalAsset.base)accrueInfo.feesEarnedFraction only increases between fee withdrawalstotalBorrow.elastic <= totalBorrowCap (when cap > 0)mint() has a corresponding burn() pathallowedMinter[chainId] can mint USDOrateTimestamp + rateValidDuration >= block.timestamp for rate-dependent operationsCritical Findings
C-1: Normal Liquidation Never Reduces totalBorrow (BigBang + Singularity)
| Field | Value |
|---|---|
| Severity | CRITICAL |
| Confidence | Confirmed |
| Category | State Invariant |
| Affected Files | BBLiquidation.sol, SGLLiquidation.sol |
In both BBLiquidation._liquidateUser() and SGLLiquidation._liquidateUser(), userBorrowPart[user] is decremented and USDO is burned (BigBang) or assets are deposited back (Singularity), but totalBorrow.elastic and totalBorrow.base are never decremented. Only the owner-only liquidateBadDebt() function correctly updates totalBorrow.
In BBLiquidation.sol:
userBorrowPart[user] -= borrowPart (user debt reduced)IUsdo(address(asset)).burn(address(this), borrowAmount) (USDO burned)totalBorrow.sub(...) call anywhere in _liquidateUser or _closedLiquidationContrast with liquidateBadDebt (lines 85-86) which correctly does:
totalBorrow.elastic -= borrowAmount.toUint128();
totalBorrow.base -= userBorrowPart[user].toUint128();
The identical pattern exists in SGLLiquidation.sol:
userBorrowPart[user] -= borrowPart (user debt reduced)totalAsset.elastic += (returnedShare - extraShare) (assets returned to pool)totalBorrow updateAfter every normal liquidation, totalBorrow permanently includes phantom debt:
totalBorrow.elastic further each accrual cycle.utilization = totalBorrow.elastic / (totalAsset + totalBorrow.elastic) is permanently overstated, causing higher interest rates for all borrowers.getDebtRate() reads totalBorrow.elastic, affecting ALL cross-market interest rates._isSolvent divides by totalBorrow.base, distorting solvency checks for every user.totalBorrow.base and the actual sum of userBorrowPart grows unboundedly.totalBorrow.base == sum(userBorrowPart[all users])
Remediation:
Add totalBorrow.sub(borrowPart, true) inside _updateBorrowAndCollateralShare() in both BBLiquidation.sol and SGLLiquidation.sol:
// In _updateBorrowAndCollateralShare, after computing borrowPart:
totalBorrow.sub(borrowPart, true);
userBorrowPart[user] -= borrowPart;
C-2: BBLeverage.sellCollateral Deposits to Wrong YieldBox Asset ID
| Field | Value |
|---|---|
| Severity | CRITICAL |
| Confidence | Confirmed |
| Category | Accounting |
| Affected Files | BBLeverage.sol |
| Affected Lines | 148-150 |
In BBLeverage.sellCollateral(), after swapping collateral for asset tokens via leverageExecutor.getAsset(), the received asset tokens are deposited into YieldBox under collateralId instead of assetId. The approval is also for the wrong token (asset instead of collateral).
// Line 147: shares computed for assetId (correct)
memoryData.shareOut = yieldBox.toShare(assetId, amountOut, false);
// Line 148: approves asset token (wrong - should be collateral if depositing to collateralId)
address(asset).safeApprove(address(yieldBox), type(uint256).max);
// Line 149: deposits to collateralId (WRONG - should be assetId)
yieldBox.depositAsset(collateralId, address(this), address(this), 0, memoryData.shareOut);
Compare with SGLLeverage.sol:135 which correctly uses assetId:
yieldBox.depositAsset(assetId, address(this), address(this), 0, shareOut);
Impact:
sellCollateral is completely broken for BigBang markets_repay call will fail or pull from the user's own balanceChange collateralId to assetId on line 149:
yieldBox.depositAsset(assetId, address(this), address(this), 0, memoryData.shareOut);
C-3: BBLeverage.buyCollateral and SGLLeverage.buyCollateral Approve Wrong Token
| Field | Value |
|---|---|
| Severity | CRITICAL |
| Confidence | Confirmed |
| Category | Accounting |
| Affected Files | BBLeverage.sol, SGLLeverage.sol |
| Affected Lines | BB: 103-105, SGL: 87-89 |
In buyCollateral(), after leverageExecutor.getCollateral() returns collateral tokens, the code approves address(asset) for YieldBox but then calls yieldBox.depositAsset(collateralId, ...). The approval is for the asset token, but YieldBox needs the collateral token approved to pull it during deposit.
// BBLeverage.sol line 103: approves ASSET token (WRONG)
address(asset).safeApprove(address(yieldBox), type(uint256).max);
// BBLeverage.sol line 104: deposits COLLATERAL (needs collateral approved, not asset)
yieldBox.depositAsset(collateralId, address(this), address(this), 0, collateralShare);
Same pattern in SGLLeverage.sol:87-88.
buyCollateral is broken for both BigBang and SingularitydepositAsset call will revert because the wrong token is approvedChange the approval to the collateral token:
address(collateral).safeApprove(address(yieldBox), type(uint256).max);
yieldBox.depositAsset(collateralId, address(this), address(this), 0, collateralShare);
address(collateral).safeApprove(address(yieldBox), 0);
High Findings
H-1: _allowedBorrow Pearlmit Integration Is Broken
| Field | Value |
|---|---|
| Severity | HIGH |
| Confidence | Confirmed |
| Category | Access Control |
| Affected Files | Market.sol |
| Affected Lines | 416-425 |
_allowedBorrow uses OR logic: require(allowanceBorrow >= share || pearlmitAllowed >= share). If only the Pearlmit allowance satisfies the check (allowanceBorrow is 0), the require passes but the subsequent allowanceBorrow -= share underflows and reverts in Solidity 0.8's checked arithmetic. The Pearlmit approval path is effectively unusable unless allowanceBorrow is independently set to type(uint256).max.
The TODO comment on line 418 (// TODO review risk of using this) confirms developer awareness of this issue.
Pearlmit-based approvals cannot be used for borrow operations. Any cross-chain flow relying solely on Pearlmit approval for borrowing will revert.
Remediation:Only decrement allowanceBorrow when it was the satisfying condition:
if (allowanceBorrow[from][msg.sender] >= share) {
if (allowanceBorrow[from][msg.sender] != type(uint256).max) {
allowanceBorrow[from][msg.sender] -= share;
}
} else {
require(pearlmitAllowed >= share, "Market: not approved");
// Consume pearlmit allowance via Pearlmit contract
}
H-2: Usdo.executeModule() Allows Direct Module Execution with Controllable srcChainSender
| Field | Value |
|---|---|
| Severity | HIGH |
| Confidence | Confirmed |
| Category | Access Control |
| Affected Files | Usdo.sol, UsdoOptionReceiverModule.sol |
executeModule() is external with only whenNotPaused — any caller can invoke any USDO module via delegatecall with arbitrary encoded data. When exerciseOptionsReceiver is called through executeModule (not through LayerZero receive), the srcChainSender parameter becomes attacker-controllable, bypassing the cross-chain authentication model.
Impact:
An attacker can call exerciseOptionsReceiver with a crafted srcChainSender. If srcChainSender == _owner, the allowance check in _internalTransferWithAllowance is skipped. This could allow unauthorized token operations for users who have USDO balance and have approved the USDO contract for cross-chain operations.
Add access control to executeModule() or validate srcChainSender against the actual LayerZero origin inside receiver modules:
function executeModule(uint8 _module, bytes memory _data, bool _forwardRevert)
external payable whenNotPaused onlyEndpoint {
// ...
}
H-3: Stale Exchange Rate (24-Hour Cache) in Solvency Checks
| Field | Value |
|---|---|
| Severity | HIGH |
| Confidence | Confirmed |
| Category | Oracle |
| Affected Files | Market.sol |
| Affected Lines | 372-385 |
The exchangeRate is cached with a rateValidDuration of 24 hours. When oracle.get() returns updated=false, the stale cached rate is used as long as rateTimestamp + rateValidDuration >= block.timestamp. In volatile markets, a 24-hour-old rate can deviate significantly from current prices.
Users can borrow against inflated collateral values or avoid legitimate liquidations during price drops. Conversely, users may be unfairly liquidated when the stale rate undervalues their collateral.
Remediation:Reduce rateValidDuration to a shorter period (e.g., 1 hour) and implement a secondary oracle fallback.
H-4: Liquidation Uses Stale Rate Without Staleness Check on Oracle Failure
| Field | Value |
|---|---|
| Severity | HIGH |
| Confidence | Confirmed |
| Category | Oracle |
| Affected Files | Market.sol |
| Affected Lines | 427-439 |
_updateOracleRateForLiquidations() has a try/catch that, on oracle failure, falls back to the stored exchangeRate without any rateValidDuration check. This is in contrast to updateExchangeRate() which enforces the staleness check.
Evidence:
catch {
if (exchangeRate == 0) revert ExchangeRateNotValid();
// No rateValidDuration check here!
}
Impact:
If an attacker can cause the oracle to revert (e.g., manipulated pool observations, deprecated Chainlink feed), liquidations proceed at an arbitrarily old rate. This enables unfair liquidations or prevents legitimate ones.
Remediation:Add the staleness check in the catch block:
catch {
require(rateTimestamp + rateValidDuration >= block.timestamp, "Market: rate too old");
if (exchangeRate == 0) revert ExchangeRateNotValid();
}
H-5: BigBang Debt Rate Cross-Market Manipulation via Flash Loans
| Field | Value |
|---|---|
| Severity | HIGH |
| Confidence | Likely |
| Category | Flash Loan / Price Manipulation |
| Affected Files | BBCommon.sol |
| Affected Lines | 45-61 |
Non-main BigBang markets compute their debt rate from the ETH main market's totalBorrow.elastic via getDebtRate(). An attacker can flash-loan collateral, borrow heavily in the ETH market to inflate _ethMarketTotalDebt, then accrue on non-main markets at the manipulated rate.
_ethMarketTotalDebt increases dramatically_maxDebtPoint increases, pushing rates toward minDebtRateUse a time-weighted average of the ETH market debt rather than a spot reading. Alternatively, introduce a minimum delay between rate changes.
H-6: Division by Zero in _isSolvent When totalBorrow.base == 0
| Field | Value |
|---|---|
| Severity | HIGH |
| Confidence | Confirmed |
| Category | Arithmetic |
| Affected Files | Market.sol |
| Affected Lines | 479 |
If totalBorrow.base reaches 0 (e.g., after liquidateBadDebt clears the last borrower) but a user retains dust borrowPart due to Rebase rounding, _isSolvent divides by totalBorrow.base (zero), causing a permanent revert. That user can never call removeCollateral because the solvent modifier reverts.
// Line 479 - divides by _totalBorrow.base
>= (borrowPart * _totalBorrow.elastic * _exchangeRate) / _totalBorrow.base;
Line 469 early-returns if borrowPart == 0, but does NOT handle totalBorrow.base == 0 when borrowPart > 0.
Permanent fund lockup for affected users. Their collateral is forever locked in the contract.
Remediation:Add a zero-check:
if (_totalBorrow.base == 0) {
return borrowPart == 0; // Solvent only if no borrow
}
H-7: Read-Only Reentrancy in Liquidation (totalAsset Stale During Callback)
| Field | Value |
|---|---|
| Severity | HIGH |
| Confidence | Likely |
| Category | Reentrancy |
| Affected Files | SGLLiquidation.sol |
In _liquidateUser(), totalAsset.elastic is updated after the external call to _liquidatorReceiver.onCollateralReceiver(). During the callback, any external protocol reading totalAsset sees stale values. The nonReentrant guard on execute() prevents re-entry into Singularity itself, but external protocols composing with Singularity during the callback window get incorrect share price data.
External DeFi protocols integrating with Singularity (e.g., vaults, yield aggregators) could make incorrect decisions based on stale totalAsset during liquidation callbacks.
Move totalAsset.elastic update before the external call, or mark all view functions that external protocols might call with appropriate reentrancy awareness.
H-8: Origins._accrueView Returns Zero Rebase — Breaks computeTVLInfo
| Field | Value |
|---|---|
| Severity | HIGH |
| Confidence | Confirmed |
| Category | State Invariant |
| Affected Files | Origins.sol |
| Affected Lines | 198-206 |
_accrueView() returns Rebase({elastic: 0, base: 0}) instead of the actual totalBorrow. computeTVLInfo() in Market.sol uses this return value for division: borrowPart = (borrowPart * _totalBorrow.elastic) / _totalBorrow.base. This causes a division-by-zero revert for any Origins borrower.
Impact:
All view functions relying on computeTVLInfo() break for Origins markets. Frontend displays, monitoring tools, and external integrations all fail.
Return the actual totalBorrow from _accrueView():
function _accrueView() internal view override returns (Rebase memory) {
return totalBorrow;
}
Medium Findings
M-1: Singularity First-Depositor Share Inflation Attack
| Field | Value |
|---|---|
| Severity | MEDIUM |
| Confidence | Likely |
| Category | ERC-4626 / Share Inflation |
| Affected Files | SGLCommon.sol |
| Affected Lines | 180-195 |
The minimum 1000-share threshold in _addAsset() is insufficient to prevent share inflation attacks. An attacker who is the first depositor can deposit 1000 shares, then donate a large amount directly to YieldBox, inflating the share price. Subsequent depositors lose value due to rounding in the fraction = (share * _totalAsset.base) / allShare calculation. A virtual share/asset offset (like OpenZeppelin's ERC4626 implementation) would provide stronger protection.
M-2: Shared Nonce Between permit() and permitBorrow()
| Field | Value |
|---|---|
| Severity | MEDIUM |
| Confidence | Confirmed |
| Category | Signature Replay |
| Affected Files | MarketERC20.sol |
| Affected Lines | 198-229 |
A single _nonces[owner] counter is used for both permit() and permitBorrow(). Submitting one permit type invalidates pending signatures of the other. While different type hashes prevent actual cross-function replay, the shared nonce creates a griefing vector — especially problematic for cross-chain composed operations where timing of permit execution is critical.
M-3: Cross-Chain Market Modules Don't Receive srcChainSender
| Field | Value |
|---|---|
| Severity | MEDIUM |
| Confidence | Likely |
| Category | Cross-Chain Security |
| Affected Files | UsdoReceiver.sol |
| Affected Lines | 76-95 |
MSG_MARKET_REMOVE_ASSET, MSG_YB_SEND_SGL_LEND_OR_REPAY, and MSG_DEPOSIT_LEND_AND_SEND_FOR_LOCK do not pass _srcChainSender to their respective receiver modules. The "user" field in the message payload is trusted implicitly without validation against the authenticated cross-chain sender. Compare with MSG_TAP_EXERCISE (line 72) which correctly passes _srcChainSender.
M-4: Unbounded allBigBangMarkets Loop in _reAccrueMarkets
| Field | Value |
|---|---|
| Severity | MEDIUM |
| Confidence | Confirmed |
| Category | DoS |
| Affected Files | Penrose.sol |
| Affected Lines | 550-563 |
_reAccrueMarkets() iterates all entries in allBigBangMarkets and calls accrue() on each registered market. This is called on every borrow and repay in BigBang (via penrose.reAccrueBigBangMarkets()). There is no mechanism to remove deprecated markets from the array. Gas cost grows linearly with the total number of markets ever registered, eventually risking block gas limit exhaustion.
M-5: SGL _repay Rounds Down Instead of Up
| Field | Value |
|---|---|
| Severity | MEDIUM |
| Confidence | Confirmed |
| Category | Arithmetic |
| Affected Files | SGLLendingCommon.sol |
| Affected Lines | 107 |
totalBorrow.sub(part, false) uses false (round down) for the elastic-to-base conversion, consistently favoring borrowers by 1 wei per repayment. BigBang's _repay (BBLendingCommon.sol:125) correctly uses true (round up). The inconsistency between the two market types confirms this is likely a bug.
M-6: _accrueView vs _accrue Year Constant Inconsistency
| Field | Value |
|---|---|
| Severity | MEDIUM |
| Confidence | Confirmed |
| Category | Arithmetic |
| Affected Files | BBCommon.sol |
| Affected Lines | 79 vs 97 |
_accrueView() uses 31536000 (365 days) while _accrue() uses 31557600 (365.25 days). This ~0.07% discrepancy causes view functions to report slightly different accrued interest than what is actually applied. Off-chain systems and frontends relying on view functions will compute incorrect values.
M-7: Seer Oracle Always Returns success=true
| Field | Value |
|---|---|
| Severity | MEDIUM |
| Confidence | Confirmed |
| Category | Oracle |
| Affected Files | gitmodule/.../oracle/Seer.sol |
| Affected Lines | 53-65 |
The Seer oracle's get() function always returns success=true (line 64) as long as it doesn't revert. It has no mechanism to signal degraded data quality. Since Market.updateExchangeRate() checks the updated return value to decide whether to refresh the cached rate, and Seer always returns true, the market will always accept the returned rate — even if it's based on stale TWAP data from an illiquid pool.
M-8: TWAP Period Configurable to 1 Second (No Minimum Floor)
| Field | Value |
|---|---|
| Severity | MEDIUM |
| Confidence | Confirmed |
| Category | Oracle |
| Affected Files | gitmodule/.../oracle/utils/UniswapUtils.sol |
| Affected Lines | 57-60 |
changeTwapPeriod() allows the GUARDIAN_ROLE to set the TWAP period to any value > 0 (minimum 1 second). A compromised guardian could set it to 1 second, making the oracle read effectively a spot price that is trivially manipulable via flash loans. A minimum floor (e.g., 30 minutes) should be enforced in code.
M-9: Chainlink Missing Explicit price > 0 and answeredInRound Checks
| Field | Value |
|---|---|
| Severity | MEDIUM |
| Confidence | Confirmed |
| Category | Oracle |
| Affected Files | gitmodule/.../oracle/utils/ChainlinkUtils.sol |
| Affected Lines | 28-39 |
_readChainlinkBase checks ratio <= minAnswer || ratio >= maxAnswer but does not explicitly check ratio > 0 or answeredInRound >= roundId. While most Chainlink feeds have minAnswer > 0, this is not guaranteed. Missing the answeredInRound check means unfinalized rounds could be accepted.
M-10: BigBang init() Missing Module Address Zero-Check
| Field | Value |
|---|---|
| Severity | MEDIUM |
| Confidence | Confirmed |
| Category | Initialization |
| Affected Files | BigBang.sol |
| Affected Lines | 121-131 |
BigBang._initModules() sets module addresses without checking for address(0). In contrast, Singularity._initModules() explicitly reverts with NotValid() if any module is zero. If a BigBang market is initialized with a zero-address module, it becomes registered but partially broken — users who deposited collateral before attempting to borrow would find the borrow module inaccessible.
Low Findings
L-1: PearlmitHash Type Hash String Inconsistency
_PERMIT_BATCH_TRANSFER_FROM_TYPEHASH references SignatureApproval without the tokenType field, but the actual _PERMIT_SIGNATURE_APPROVAL_TYPEHASH includes tokenType. If the precomputed constant was derived from the wrong string, EIP-712 signature verification would produce incorrect results.
File: gitmodule/.../pearlmit/PearlmitHash.sol:20-26
L-2: Conservator Pause/Unpause Has No Timelock
The conservator can instantly toggle pause states for any market operation. No timelock or multi-sig requirement exists. If the conservator key is compromised, an attacker could unpause a market that was paused during an active exploit.
Files:BigBang.sol:237-264, Singularity.sol:258-270, Penrose.sol:341-346
L-3: Origins allowedParticipants Has No Setter Function
Only the constructor sets allowedParticipants[_owner] = true. No function exists to add or remove participants post-deployment. If the owner address needs to be rotated, the entire Origins market becomes unusable.
Origins.sol:42, 74
L-4: _extractLiquidationFees Max Approval Pattern
_extractLiquidationFees() unconditionally approves type(uint256).max to YieldBox before checking if callerShare > 0. While the approval is reset to 0 afterwards, the pattern is fragile and wastes gas when callerShare == 0.
File: BBLiquidation.sol:240-243
Invariants Verified
The following invariants were tested and confirmed to hold:
| Invariant | Status |
|---|---|
solvent modifier operates as correct post-condition check | HELD |
nonReentrant on execute() prevents cross-function reentrancy within markets | HELD |
| Cluster whitelist on leverage executors provides defense-in-depth | HELD |
| SafeApprove library correctly handles approve-to-zero-first pattern | HELD |
onlyOnce guard prevents re-initialization of markets | HELD |
| Ownership transfers to Penrose during market init | HELD |
setMarketConfig validates collateralizationRate * (1 + liquidationMultiplier) < 1 | HELD |
| ERC20 permit uses correct EIP-712 domain separator with chainId | HELD |
Methodology
Phase 1: Setup
Phase 2: Map
Phase 3: Hunt (Deep Mode)
5 parallel agents scanned all contracts:
| Agent | Vector Group | Model |
|---|---|---|
| Agent 1 | Reentrancy + External Calls + Token Safety | Sonnet |
| Agent 2 | Oracle + Flash Loans + Price Manipulation | Sonnet |
| Agent 3 | Access Control + Signature Replay + Proxy/Init | Sonnet |
| Agent 4 | Arithmetic + State Invariants + DoS/Griefing | Sonnet |
| Agent 5 | Adversarial Reasoning (MEV bot perspective) | Opus |
Phase 4: Attack
Phase 5: Report
*Report generated by SC Mega Auditor*