Architecture
March 30, 2026 · View on GitHub
Overview
The Sablier Staking protocol distributes rewards to users over time who stake ERC20 tokens in staking pools. The protocol supports two types of staking:
- Direct ERC20 Token Staking: Users can directly stake ERC20 tokens.
- Lockup NFT Staking: Users can stake Sablier Lockup NFTs that are streaming the supported staking token.
The protocol implements a snapshot-based reward calculation mechanism.
Notation
| Symbol | Description |
|---|---|
| Event such as stake, claim rewards etc. | |
| Time elapsed between events | |
| Total amount staked globally | |
| Amount staked by user | |
| Global rewards per token | |
| User's rewards per token | |
| Total global rewards distributed | |
| Total rewards earned by a user |
Technical Design
1. Snapshot-Based Reward System
The protocol uses a snapshot mechanism to calculate user rewards:
Global Snapshots
It tracks cumulative rewards distributed per token. The cumulative rewards per token at event is calculated as:
Where the rewards distributed in period is:
So, the total rewards distributed until becomes
Of course, by the end of the reward period, must become equal to the pool rewards.
User Snapshots
It tracks rewards earned by each user using the global rewards distributed per token.
Now, to calculate user rewards, we first calculate rewards per token distributed since the last time user snapshot was taken.
When a user performs an action at event , their pending rewards can be calculated as:
The user's total claimable rewards become:
2. Dual Staking Model
Direct ERC20 Staking
- Users stake tokens directly to the contract
- Rewards earned on the amount staked
Lockup NFT Staking
- Users stake stream NFTs into the contract
- Rewards earned on the total tokens in the stream (including both locked and unlocked)
- Prevents withdrawal from staked streams
- Handles stream cancellation events
3. Other Considerations
- Scaling Factor: Amounts are scaled to 1e20 decimals for higher precision during divisions, preventing precision loss in reward calculations regardless of the token decimals.
- No Staking: No rewards are distributed when no tokens are staked
Diagrams
Statuses
| Status | Description |
|---|---|
SCHEDULED | When start time is in the future. |
ACTIVE | When current timestamp is in between start time and end time. |
ENDED | When end time is in the past. |
Statuses diagram
stateDiagram-v2
direction LR
NULL --> SCHEDULED : createPool(startTime > now)
NULL --> ACTIVE : createPool(startTime = now)
SCHEDULED --> ACTIVE : time
ACTIVE --> ENDED : time
ENDED --> SCHEDULED : configureNextRound(startTime > now)
ENDED --> ACTIVE : configureNextRound(startTime = now)
NULL:::grey
SCHEDULED:::lightYellow
ACTIVE:::lightGreen
ENDED:::lightRed
classDef grey fill:#b0b0b0,stroke:#333,stroke-width:2px;
classDef lightGreen fill:#98FB98;
classDef lightYellow fill:#ffff99;
classDef lightRed fill:#ff4e4e;
Function calls
flowchart LR
subgraph Statuses
NULL((NULL)):::grey
ACTIVE((ACTIVE)):::green
SCHEDULED((SCHEDULED)):::yellow
ENDED((ENDED)):::red
end
subgraph Functions
CLAIM_REWARDS([claimRewards])
CONFIGURE([configureNextRound])
CREATE([createPool])
STAKE([stakeERC20Token / stakeLockupNFT])
UNSTAKE([unstakeERC20Token / unstakeLockupNFT])
end
ALL(( )):::black
AS(( )):::black
classDef black fill:#000000,stroke:#333,stroke-width:2px;
classDef green fill:#32cd32,stroke:#333,stroke-width:2px;
classDef grey fill:#b0b0b0,stroke:#333,stroke-width:2px;
classDef yellow fill:#ffff99,stroke:#333,stroke-width:2px;
classDef red fill:#ff4e4e,stroke:#333,stroke-width:2px;
ALL --> ACTIVE & SCHEDULED & ENDED
AS --> ACTIVE & SCHEDULED
CREATE --> NULL
CONFIGURE -- "Snapshot Rewards" --> ENDED
CLAIM_REWARDS -- "Snapshot Rewards" --> ALL
STAKE -- "Snapshot Rewards" --> AS
UNSTAKE -- "Snapshot Rewards" --> ALL