Version 2 Released! Our biggest documentation upgrade to date.

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

Property
Value

Contract

sthUSD.sol

Standard

ERC-4626 Vault with Tharwa extensions

Address

0xf7Af0A8079F12F19533B0DF69ce7ee6718b0C46f (Mainnet)

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)

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)

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 security

  • Fee 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

  1. Attacker deposits 1 wei to get shares

  2. Attacker donates 1000 thUSD directly to contract

  3. Share price skyrockets artificially

  4. New depositors get almost no shares for their deposits

  5. 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 yet

  • Result: 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:

Without Vesting: Someone could deposit right before yield is added, immediately claim a large portion, then withdraw

With Vesting: Yield is distributed fairly over time to users who were actually staked when it was earned

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:

Cooldown Period
Behavior
Use Case

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

Fee Type
Current Rate
Maximum
Applied When

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");
Role
Permissions
Purpose

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

  1. Pausable: Emergency pause for all user-facing functions

  2. Blacklist: Compliance-focused address restrictions

  3. Reentrancy Protection: All external functions protected

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

Operation
Gas Cost
Optimization

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:

  1. totalAssets() >= totalSupply() * minSharePrice

  2. _pooledAssets >= totalAssets() + unvestedAmount()

  3. sum(balanceOf(users)) == totalSupply()

  4. 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);
    }
}

Developer Resources

Last updated