> For the complete documentation index, see [llms.txt](https://docs.theo.xyz/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.theo.xyz/developers/contract-reference/sthusd.md).

# sthUSD

The sthUSD staking vault. ERC-4626 over thUSD with vesting yield and asynchronous, lockup-gated redemption.

|                          |                                                                                                                         |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| Proxy                    | [`0xA808Bc9775cb41c52C7842f8b50427fE7A770326`](https://etherscan.io/address/0xA808Bc9775cb41c52C7842f8b50427fE7A770326) |
| Implementation           | [`0x342487F9Fe31d199Dda8C89a1c34cc647c5A7dC3`](https://etherscan.io/address/0x342487F9Fe31d199Dda8C89a1c34cc647c5A7dC3) |
| Contract                 | `SthUSD` in `contracts/thusd/SthUSD.sol`                                                                                |
| Source                   | [Sourcify, exact match](https://repo.sourcify.dev/1/0x342487F9Fe31d199Dda8C89a1c34cc647c5A7dC3), solc 0.8.28            |
| Name / symbol / decimals | `Staked thUSD` / `sthUSD` / `6`                                                                                         |
| `asset()`                | thUSD, `0xa3fE…85b3`                                                                                                    |
| Owner                    | Timelock, `0x2bB4…CA02`, 2-day delay                                                                                    |
| `emergencyPauser`        | Guardian, `0xF936…DD8F`                                                                                                 |
| `yieldDistributor`       | `0x5a69fb3e2fb7e78dfbdceafc5365d5803b745aac`                                                                            |
| `lockupPeriod`           | 432,000 seconds (5 days). Code maximum 30 days.                                                                         |
| `vestingDuration`        | 10,800 seconds (3 hours). Code maximum 365 days.                                                                        |

## Inheritance

`ERC4626Upgradeable` → `ERC20PermitUpgradeable` → `Ownable2StepUpgradeable` → `UUPSUpgradeable` → `PausableUpgradeable` → `ISthUSD`

OpenZeppelin Contracts Upgradeable v5 throughout. The vault overrides `totalAssets`, `deposit`, `mint`, `withdraw`, `redeem`, all four `max*` views, and both exit `preview*` views. It adds the yield and redemption-request machinery described below.

## How value is accounted

```
totalAssets() = thUSD.balanceOf(vault) − getUnvestedAmount() − pendingRedeemAssets
```

Three quantities of thUSD sit in the vault and only one of them backs live shares:

* **Backing** for outstanding shares. This is what `totalAssets()` returns and what `convertToAssets` prices against.
* **Unvested yield.** When the distributor calls `setYield`, the full amount lands in the vault but is recognised linearly over `vestingDuration`. `getUnvestedAmount()` is the portion not yet recognised, rounded up.
* **Pending redemptions.** When a holder calls `initiateRedeem`, their shares are burned and the thUSD they are owed is set aside. It stops earning and stops counting toward backing.

Share price is `convertToAssets(1e6)`. It moves only as yield vests. No owner function moves thUSD out of the vault, so no admin action can lower it.

## Yield

`setYield(uint256 amount)` is callable only by `yieldDistributor` while not paused. It requires `vestingDuration > 0`, requires the previous batch to be fully vested, transfers `amount` of thUSD from the distributor into the vault, records `yieldAmount = amount` and `lastYieldTimestamp = now`, and emits `YieldTransferredIn(amount, vestingDuration)`.

```
getUnvestedAmount() = 0                                          if now − lastYieldTimestamp ≥ vestingDuration
                    = ceil(yieldAmount × (vestingDuration − elapsed) / vestingDuration)   otherwise
```

One batch vests at a time. A second `setYield` during a vesting window reverts `VestingInProgress()`. `setVestingDuration` also reverts during a window, and when it succeeds it zeroes `yieldAmount` and `lastYieldTimestamp`.

## Redemption lifecycle

```mermaid
stateDiagram-v2
    [*] --> Holding: deposit / mint
    Holding --> Pending: initiateRedeem(shares) burns shares, books assets
    Pending --> Pending: initiateRedeem again merges and restarts lockup
    Pending --> Claimable: lockupPeriod elapses
    Claimable --> Claimable: partial redeem / withdraw
    Claimable --> [*]: full redeem / withdraw
```

Each address has one request slot, `RedeemRequestData { assets, shares, claimableTimestamp }`, keyed to the address that called `initiateRedeem`.

**`initiateRedeem(uint256 shares, address owner)`**

1. Reverts if paused or `shares == 0`.
2. If the caller is not `owner`, spends the caller's allowance on `owner`'s shares.
3. Computes `assets = convertToAssets(shares)` at the current rate and burns the shares from `owner`.
4. If this burn takes `totalSupply` to zero, the request receives every thUSD left in the vault except other pending claims, including any unvested yield, and the vesting counters reset.
5. Adds `assets` and `shares` to the caller's request, sets `claimableTimestamp = now + lockupPeriod`, and adds `assets` to the pending total.
6. Emits `RedeemRequest(destination = caller, owner, sender = caller, shares, assets)`.

Because the timestamp is overwritten on every call, adding to an open request restarts the lockup for the whole request.

**`redeem(uint256 shares, address receiver, address owner)`** and **`withdraw(uint256 assets, address receiver, address owner)`** are the claim functions.

1. Revert if paused, if the amount is zero, if `owner` has no request, if the lockup has not elapsed, or if the amount exceeds the request.
2. A full claim transfers the whole booked amount to avoid dust. A partial `redeem` rounds assets down; a partial `withdraw` rounds shares up, and if rounding consumes all remaining shares the full asset amount is paid.
3. Revert `OwnerMustCompleteRedeem()` if `msg.sender != owner`. The `owner` here is the request holder, which is the address that initiated. Allowances do not apply to claims.
4. Reduce the request and the pending total, transfer thUSD to `receiver`, and emit the ERC-4626 `Withdraw` event.

## Pause

`pause()` is callable only by `emergencyPauser`, and reverts if that address is unset. `unpause()` is owner only. While paused: `deposit`, `mint`, `initiateRedeem`, `redeem`, `withdraw`, and `setYield` revert; `maxDeposit`, `maxMint`, `maxWithdraw`, and `maxRedeem` return `0`. Transfers, approvals, `permit`, and all pricing views keep working.

## Functions

### ERC-4626 core

| Function                                                             | Access                     | Behaviour                                                                       |
| -------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------- |
| `asset()`                                                            | view                       | thUSD                                                                           |
| `totalAssets()`                                                      | view                       | Balance minus unvested yield minus pending redemptions                          |
| `convertToShares(uint256 assets)`, `convertToAssets(uint256 shares)` | view                       | Standard OZ, floor rounding with a virtual offset of `10^0`                     |
| `previewDeposit(uint256)`, `previewMint(uint256)`                    | view                       | Standard                                                                        |
| `previewWithdraw(uint256)`, `previewRedeem(uint256)`                 | pure                       | **Always `0`.** Marked deprecated in source; use `maxWithdraw` and `maxRedeem`. |
| `maxDeposit(address)`, `maxMint(address)`                            | view                       | `0` if paused, otherwise `type(uint256).max`                                    |
| `maxWithdraw(address owner)`, `maxRedeem(address owner)`             | view                       | The claimable request of `owner`, or `0` if none, not yet claimable, or paused  |
| `deposit(uint256 assets, address receiver)`                          | anyone, not paused         | Standard. Pulls thUSD via `transferFrom`, mints shares, emits `Deposit`.        |
| `mint(uint256 shares, address receiver)`                             | anyone, not paused         | Standard                                                                        |
| `withdraw(uint256 assets, address receiver, address owner)`          | request holder, not paused | Claim. See lifecycle above.                                                     |
| `redeem(uint256 shares, address receiver, address owner)`            | request holder, not paused | Claim. See lifecycle above.                                                     |

### Redemption requests

| Function                                                                 | Access             | Behaviour                                                           |
| ------------------------------------------------------------------------ | ------------------ | ------------------------------------------------------------------- |
| `initiateRedeem(uint256 shares, address owner)`                          | anyone, not paused | Burns shares, books a claim to the caller                           |
| `pendingRedeemRequest(address)` → `uint256 shares`                       | view               | Booked shares if the lockup is still running, else `0`              |
| `claimableRedeemRequest(address)` → `uint256 shares`                     | view               | Booked shares if the lockup has elapsed and assets remain, else `0` |
| `currentRedeemRequest(address)` → `(assets, shares, claimableTimestamp)` | view               | The raw request                                                     |

### Yield

| Function                                                                           | Access                         | Behaviour                                  |
| ---------------------------------------------------------------------------------- | ------------------------------ | ------------------------------------------ |
| `setYield(uint256 amount)`                                                         | `yieldDistributor`, not paused | Pulls thUSD in and starts a vesting window |
| `getUnvestedAmount()`                                                              | view                           | Yield not yet recognised                   |
| `yieldAmount()`, `lastYieldTimestamp()`, `vestingDuration()`, `yieldDistributor()` | view                           | Current yield state                        |
| `MAX_VESTING_DURATION()`                                                           | view                           | `31536000` (365 days)                      |

### Owner

| Function                                                                 | Access            | Behaviour                                                                                             |
| ------------------------------------------------------------------------ | ----------------- | ----------------------------------------------------------------------------------------------------- |
| `setLockupPeriod(uint256)`                                               | owner             | Must be ≤ 30 days. Applies to future requests only. Emits `LockupPeriodUpdated`.                      |
| `setVestingDuration(uint256)`                                            | owner             | Must be ≤ 365 days and no vesting in progress. Resets yield counters. Emits `VestingDurationUpdated`. |
| `setYieldDistributor(address)`                                           | owner             | Emits `YieldDistributorUpdated`                                                                       |
| `setEmergencyPauser(address)`                                            | owner             | Emits `EmergencyPauserUpdated`                                                                        |
| `unpause()`                                                              | owner             |                                                                                                       |
| `pause()`                                                                | `emergencyPauser` |                                                                                                       |
| `lockupPeriod()`, `emergencyPauser()`, `paused()`, `MAX_LOCKUP_PERIOD()` | view              | `MAX_LOCKUP_PERIOD` is `2592000` (30 days)                                                            |

### Token, permit, ownership, upgrade

Identical to [thUSD](/developers/contract-reference/thusd.md): standard ERC-20 surface, EIP-2612 `permit` under domain `Staked thUSD` version `1`, `Ownable2Step` with `transferOwnership` and `acceptOwnership`, and UUPS `upgradeToAndCall` gated to the owner. `supportsInterface` returns true for `IERC4626`, `IERC165`, and `ISthUSD`.

## Events

| Event                                                                                                               | Emitted by                                                        |
| ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares)`                            | `deposit`, `mint`                                                 |
| `RedeemRequest(address indexed destination, address indexed owner, address sender, uint256 shares, uint256 assets)` | `initiateRedeem`. `destination` and `sender` are both the caller. |
| `Withdraw(address indexed sender, address indexed receiver, address indexed owner, uint256 assets, uint256 shares)` | `redeem`, `withdraw` claims                                       |
| `YieldTransferredIn(uint256 amount, uint256 vestingDuration)`                                                       | `setYield`                                                        |
| `LockupPeriodUpdated(uint256 oldLockupPeriod, uint256 newLockupPeriod)`                                             | `setLockupPeriod`                                                 |
| `VestingDurationUpdated(uint256 oldVestingDuration, uint256 newVestingDuration)`                                    | `setVestingDuration`                                              |
| `YieldDistributorUpdated(address indexed old, address indexed new)`                                                 | `setYieldDistributor`                                             |
| `EmergencyPauserUpdated(address indexed old, address indexed new)`                                                  | `setEmergencyPauser`                                              |
| `Paused(address account)`, `Unpaused(address account)`                                                              | `pause`, `unpause`                                                |
| `Transfer`, `Approval`                                                                                              | ERC-20                                                            |
| `OwnershipTransferStarted`, `OwnershipTransferred`, `Upgraded`, `Initialized`, `EIP712DomainChanged`                | Inherited                                                         |

## Errors

| Error                                                                                                           | When                                                                                                        |
| --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `ZeroAssetsOrShares()`                                                                                          | Zero amount to `initiateRedeem`, `redeem`, `withdraw`, or `setYield`, or shares that convert to zero assets |
| `NoClaimableRequest()`                                                                                          | Claim with no open request                                                                                  |
| `LockupNotElapsed()`                                                                                            | Claim before `claimableTimestamp`                                                                           |
| `ExceedsClaimable(uint256 requested, uint256 available)`                                                        | Claim larger than the request                                                                               |
| `OwnerMustCompleteRedeem()`                                                                                     | Claim by an address other than the request holder                                                           |
| `InvalidYieldDistributor()`                                                                                     | `setYield` by anyone other than the distributor                                                             |
| `VestingDurationNotSet()`                                                                                       | `setYield` while `vestingDuration == 0`                                                                     |
| `VestingInProgress()`                                                                                           | `setYield` or `setVestingDuration` during a window                                                          |
| `MaxLockupPeriodExceeded()`, `MaxVestingDurationExceeded()`                                                     | Owner setters above their caps                                                                              |
| `NotAuthorizedToPause()`                                                                                        | `pause` by anyone other than a set `emergencyPauser`                                                        |
| `EnforcedPause()`, `ExpectedPause()`                                                                            | Pausable guards                                                                                             |
| `ERC4626ExceededMaxDeposit`, `ERC4626ExceededMaxMint`, `ERC4626ExceededMaxWithdraw`, `ERC4626ExceededMaxRedeem` | Inherited. In practice only the deposit and mint variants can fire, and only while paused.                  |
| ERC-6093, EIP-2612, Ownable, UUPS, Initializable, ECDSA errors                                                  | As on [thUSD](/developers/contract-reference/thusd.md)                                                      |

## Integration notes

* **Do not call `redeem` or `withdraw` without a prior `initiateRedeem`.** They are claims. Wallets and aggregators that expect instant 4626 exits will revert with `NoClaimableRequest()`.
* **Do not use `previewRedeem` or `previewWithdraw`.** They return `0`. Price exits with `convertToAssets`, and read what is collectable with `maxRedeem` or `maxWithdraw`.
* **The claimant is the initiator.** If a router or operator calls `initiateRedeem` on a user's behalf using an allowance, the request belongs to the router, and only the router can claim it. Pass the user's address as `receiver` at claim time.
* **Adding to an open request restarts the clock** for the entire request. Users who unstake in tranches should wait for the first claim before starting a second, or accept the reset.
* **Booked redemptions stop earning.** The thUSD amount is fixed at `initiateRedeem`, at that block's exchange rate.
* **Share price moves in small steps.** With a 3-hour vesting window, `convertToAssets` drifts upward over three hours after each `setYield` and is flat otherwise. Oracles sampling the price will see that shape.
* **A deposit during a vesting window shares in the remainder of that window.** The exposure is bounded by the window length.
* **Decimals are 6.** `convertToAssets(1000000)` returns thUSD per one sthUSD in 6-decimal units.
* **Transfers are never paused.** sthUSD stays movable during an emergency pause; only vault operations stop.

## Verify

```bash
RPC=https://ethereum-rpc.publicnode.com
STHUSD=0xA808Bc9775cb41c52C7842f8b50427fE7A770326

cast call $STHUSD 'asset()(address)'            --rpc-url $RPC   # 0xa3fe5c7596024e6811e14f029937d5bd8ae485b3
cast call $STHUSD 'owner()(address)'            --rpc-url $RPC   # 0x2bb4b7e6e83fa6b77d0143dad631843cb73dca02
cast call $STHUSD 'emergencyPauser()(address)'  --rpc-url $RPC   # 0xf936df06d35a2f82f26083f32ff2ab72f3ebdd8f
cast call $STHUSD 'yieldDistributor()(address)' --rpc-url $RPC   # 0x5a69fb3e2fb7e78dfbdceafc5365d5803b745aac
cast call $STHUSD 'lockupPeriod()(uint256)'     --rpc-url $RPC   # 432000
cast call $STHUSD 'vestingDuration()(uint256)'  --rpc-url $RPC   # 10800
cast call $STHUSD 'paused()(bool)'              --rpc-url $RPC   # false
cast call $STHUSD 'convertToAssets(uint256)(uint256)' 1000000 --rpc-url $RPC
cast call $STHUSD 'getUnvestedAmount()(uint256)' --rpc-url $RPC
```
