sthUSD Implementation
sthUSD transforms idle thUSD into a yield-generating asset while maintaining the liquidity and stability users expect from stablecoins. This page explains how sthUSD works under the hood, from its ERC-4626 foundation to the custom mechanisms that make it secure and efficient.
What Problem Does sthUSD Solve?
Traditional stablecoins force users to choose between yield and liquidity. You can either:
Hold USDC (liquid but no yield)
Lock tokens in vaults (yield but no liquidity)
sthUSD eliminates this trade-off by providing both yield and liquidity through an intelligent vault design.
Contract Overview
Contract
sthUSD.sol
Standard
ERC-4626 Vault with Tharwa extensions
Address
0xf7Af0A8079F12F19533B0DF69ce7ee6718b0C46f
(Mainnet)
Audit
Security
0 Critical, 0 High findings
How sthUSD Works: Core Concepts
The Share-Based Model
sthUSD uses the ERC-4626 vault standard, which represents ownership through shares rather than direct token amounts. Here's why this matters:
Problem: Direct 1:1 token staking
100 tokens in = 100 tokens out
Yield added by minting new tokens
Creates inflation and dilution
Example: Stake 1000 USDC, earn 50 USDC in new tokens
Asset Definitions
sthUSD manages one primary asset with sophisticated accounting:
asset()
- thUSD (The Underlying Token)
asset()
- thUSD (The Underlying Token)This represents the ERC-20 token that users deposit to earn yield. In sthUSD's case, this is always thUSD (0x76972F054aB43829064d31fF5f3AcC5Fabe57FE8
).
Key Point: Users deposit thUSD and receive sthUSD shares. The value of these shares grows as the vault earns yield from Tharwa's RWA portfolio.
shares
- sthUSD (The Vault Token)
shares
- sthUSD (The Vault Token)These represent proportional ownership in the vault. Unlike many DeFi protocols where shares are minted/burned arbitrarily, sthUSD shares have real economic meaning:
shareValue = totalAssets() / totalSupply()
As totalAssets()
grows through yield, each share becomes worth more thUSD.
State Variables Explained
sthUSD tracks several key variables to maintain security and prevent manipulation:
contract sthUSD is ERC4626, AccessControl, Pausable, ERC20Permit {
// === CORE ACCOUNTING ===
uint256 private _pooledAssets; // Total thUSD we've accounted for
uint256 private _yieldAmount; // How much yield is currently vesting
uint256 private _vestingEnd; // When the current yield finishes vesting
uint256 private _vestingPeriod; // How long yield takes to vest (30 days)
// === COOLDOWN SYSTEM ===
uint256 private _cooldownPeriod; // How long users must wait to withdraw
ThUSDSilo private _silo; // Where assets sit during cooldown
// === FEE SYSTEM ===
uint256 private _entryFeeBps; // Fee charged on deposits (basis points)
uint256 private _exitFeeBps; // Fee charged on withdrawals (basis points)
address private _feeRecipient; // Where fees are sent
}
Why These Variables Matter:
_pooledAssets
: Prevents "donation attacks" where someone sends tokens directly to manipulate share price_yieldAmount
&_vestingEnd
: Ensures new depositors can't immediately claim yield that was earned before they arrived_cooldownPeriod
&_silo
: Provides flexibility between instant liquidity and enhanced securityFee variables: Allow protocol sustainability without hardcoded rates
Accounting Model: How Value Accrues
The Core Challenge: Preventing Manipulation
The biggest challenge in yield-bearing tokens is preventing "donation attacks" where malicious actors manipulate share prices. Here's how sthUSD solves this:
Scenario: Malicious actor exploits naive accounting
Attacker deposits 1 wei to get shares
Attacker donates 1000 thUSD directly to contract
Share price skyrockets artificially
New depositors get almost no shares for their deposits
Attacker withdraws at inflated price
Result: New users get rugged, attacker profits
Total Assets Calculation
The heart of sthUSD's security is its totalAssets()
function:
function totalAssets() public view override returns (uint256) {
return _pooledAssets - _unvestedAmount();
}
Breaking this down:
_pooledAssets
: Only includes legitimate deposits and properly added yield_unvestedAmount()
: Excludes yield that hasn't finished vesting yetResult: Share price reflects only "earned" and "available" assets
Yield Vesting: Preventing Yield Theft
When Tharwa adds yield to sthUSD, it doesn't become immediately available. Instead, it vests linearly over 30 days:
function _unvestedAmount() internal view returns (uint256) {
if (block.timestamp >= _vestingEnd) return 0;
uint256 timeRemaining = _vestingEnd - block.timestamp;
return _yieldAmount.mulDiv(timeRemaining, _vestingPeriod);
}
Why Vesting Matters:
Example:
Day 0: 1000 thUSD yield added, starts 30-day vest
Day 15: 50% of yield (500 thUSD) is vested and included in
totalAssets()
Day 30: 100% of yield (1000 thUSD) is fully vested
Asset Flow Tracking
// Deposit flow
function _deposit(address caller, address receiver, uint256 assets, uint256 shares)
internal override
{
// Calculate and deduct entry fee
uint256 fee = assets.mulDiv(_entryFeeBps, 10_000);
uint256 netAssets = assets - fee;
// Update pooled assets
_pooledAssets += netAssets;
// Transfer fee to recipient
if (fee > 0) {
IERC20(asset()).safeTransferFrom(caller, _feeRecipient, fee);
}
// Continue with standard ERC4626 deposit
super._deposit(caller, receiver, netAssets, shares);
}
Yield Vesting Mechanism
function addYield(uint256 amount) external onlyRole(YIELD_MANAGER_ROLE) {
if (totalSupply() == 0) revert NoShares();
if (_unvestedAmount() != 0) revert VestingActive();
// Transfer yield into the vault
IERC20(asset()).safeTransferFrom(msg.sender, address(this), amount);
// Update accounting
_pooledAssets += amount;
_yieldAmount = amount;
_vestingEnd = block.timestamp + _vestingPeriod;
emit YieldAdded(amount, _vestingEnd);
}
Cooldown System
Cooldown States
The cooldown system provides flexibility for different withdrawal preferences:
0
(OFF)
Standard ERC4626 withdraw/redeem
DeFi integrations, instant liquidity
3 days
Staged cooldown via silo
Reduced exit fees, planned withdrawals
7 days
Extended cooldown via silo
Maximum security, institutional use
Cooldown Implementation
function cooldownAssets(uint256 assets) external whenNotPaused nonReentrant {
if (_cooldownPeriod == 0) revert CooldownIsOff();
address owner = msg.sender;
uint256 shares = previewWithdraw(assets);
// Calculate exit fee
uint256 fee = assets.mulDiv(_exitFeeBps, 10_000);
uint256 netAssets = assets - fee;
// Update accounting
_pooledAssets -= assets;
// Transfer to silo for cooldown
IERC20(asset()).safeTransfer(address(_silo), netAssets);
_silo.deposit(owner, netAssets, block.timestamp + _cooldownPeriod);
// Burn shares and handle fee
_burn(owner, shares);
if (fee > 0) {
IERC20(asset()).safeTransfer(_feeRecipient, fee);
}
emit CooldownInitiated(owner, assets, netAssets, block.timestamp + _cooldownPeriod);
}
Silo Contract
The ThUSDSilo
contract manages assets during cooldown:
contract ThUSDSilo {
struct CooldownInfo {
uint256 amount;
uint256 cooldownEnd;
}
mapping(address => CooldownInfo) public cooldowns;
function deposit(address user, uint256 amount, uint256 cooldownEnd) external onlyVault {
cooldowns[user].amount += amount;
cooldowns[user].cooldownEnd = cooldownEnd; // Latest cooldown resets timer
}
function withdraw(address user, address receiver) external onlyVault returns (uint256) {
CooldownInfo storage info = cooldowns[user];
require(block.timestamp >= info.cooldownEnd, "Cooldown active");
uint256 amount = info.amount;
delete cooldowns[user];
IERC20(thUSD).safeTransfer(receiver, amount);
return amount;
}
}
Fee Structure
Fee Configuration
function setFees(
uint256 entryFeeBps,
uint256 exitFeeBps,
address feeRecipient
) external onlyRole(FEE_MANAGER_ROLE) {
if (entryFeeBps > 1000 || exitFeeBps > 1000) revert FeeTooHigh(); // Max 10%
if (feeRecipient == address(0)) revert RecipientZero();
_entryFeeBps = entryFeeBps;
_exitFeeBps = exitFeeBps;
_feeRecipient = feeRecipient;
emit FeesUpdated(entryFeeBps, exitFeeBps, feeRecipient);
}
Current Fee Parameters
Entry Fee
0 bps (0%)
1000 bps (10%)
Deposit/Mint
Exit Fee
0 bps (0%)
1000 bps (10%)
Withdraw/Redeem/Cooldown
Access Control & Security
Role-Based Permissions
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
bytes32 public constant YIELD_MANAGER_ROLE = keccak256("YIELD_MANAGER_ROLE");
bytes32 public constant FEE_MANAGER_ROLE = keccak256("FEE_MANAGER_ROLE");
DEFAULT_ADMIN_ROLE
All administrative functions
Protocol governance
PAUSER_ROLE
Pause/unpause contract
Emergency response
YIELD_MANAGER_ROLE
Add yield to vault
Treasury operations
FEE_MANAGER_ROLE
Configure fee parameters
Fee optimization
Security Features
Pausable: Emergency pause for all user-facing functions
Blacklist: Compliance-focused address restrictions
Reentrancy Protection: All external functions protected
Safe Math: OpenZeppelin's Math library for overflow protection
function pause() external onlyRole(PAUSER_ROLE) {
_pause();
}
function setBlacklisted(address account, bool blacklisted)
external onlyRole(DEFAULT_ADMIN_ROLE)
{
_blacklisted[account] = blacklisted;
emit BlacklistUpdated(account, blacklisted);
}
Gas Optimization
Efficient Storage Layout
// Packed struct to minimize storage slots
struct VaultState {
uint128 pooledAssets; // Sufficient for 3.4e38 tokens
uint128 yieldAmount; // Sufficient for yield amounts
uint64 vestingEnd; // Timestamp fits in uint64
uint64 vestingPeriod; // Duration fits in uint64
uint32 cooldownPeriod; // Duration fits in uint32
uint16 entryFeeBps; // Max 65535 bps (655.35%)
uint16 exitFeeBps; // Max 65535 bps (655.35%)
}
Gas-Efficient Operations
Deposit
~50,000
Minimal external calls
Withdraw
~45,000
Direct asset transfer
Cooldown
~60,000
Single silo interaction
Add Yield
~40,000
Simple accounting update
Testing & Validation
Comprehensive Test Suite
contract sthUSDTest is Test {
function testDepositWithdraw() public {
// Standard ERC4626 functionality
}
function testYieldVesting() public {
// Yield vesting mechanics
}
function testCooldownSystem() public {
// Cooldown and silo interactions
}
function testDonationAttackResistance() public {
// Security against share price manipulation
}
function testFeeCalculations() public {
// Entry and exit fee mechanics
}
}
Invariant Testing
Key invariants maintained throughout all operations:
totalAssets() >= totalSupply() * minSharePrice
_pooledAssets >= totalAssets() + unvestedAmount()
sum(balanceOf(users)) == totalSupply()
cooldownAssets + vaultAssets == totalPooledAssets
Integration Examples
Basic Integration
interface ISThUSD {
function deposit(uint256 assets, address receiver) external returns (uint256 shares);
function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares);
function totalAssets() external view returns (uint256);
function previewDeposit(uint256 assets) external view returns (uint256 shares);
}
contract TharwaIntegration {
IsthUSD constant sthUSD = IsthUSD(0xf7Af0A8079F12F19533B0DF69ce7ee6718b0C46f);
IERC20 constant thUSD = IERC20(0x76972F054aB43829064d31fF5f3AcC5Fabe57FE8);
function stake(uint256 amount) external {
thUSD.transferFrom(msg.sender, address(this), amount);
thUSD.approve(address(sthUSD), amount);
uint256 shares = sthUSD.deposit(amount, msg.sender);
emit Staked(msg.sender, amount, shares);
}
}
Advanced Integration with Cooldown
contract AdvancedIntegration {
function initiateWithdrawal(uint256 assets) external {
if (sthUSD.cooldownPeriod() > 0) {
// Use cooldown system
sthUSD.cooldownAssets(assets);
} else {
// Direct withdrawal
sthUSD.withdraw(assets, msg.sender, msg.sender);
}
}
function completeWithdrawal() external {
sthUSD.unstake(msg.sender);
}
}
Production Ready sthUSD is fully audited, tested, and operational on Ethereum mainnet with zero critical findings from PrismSec.
Last updated