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:

  1. Direct ERC20 Token Staking: Users can directly stake ERC20 tokens.
  2. 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

SymbolDescription
eeEvent such as stake, claim rewards etc.
TTTime elapsed between events
AgA_gTotal amount staked globally
AuA_uAmount staked by user
rptgrpt_gGlobal rewards per token
rpturpt_uUser's rewards per token
RgR_gTotal global rewards distributed
RuR_uTotal 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 ee is calculated as:

rptg(e)=rptg(e1)+rewards distributed in period [e1,e]Ag(e)rpt_g(e) = rpt_g(e-1) + \frac{\text{rewards distributed in period } [e-1, e]}{A_g(e)}

Where the rewards distributed in period [e1,e][e-1, e] is:

rewards distributed=total pool rewardsTreward period\text{rewards distributed} = \frac{\text{total pool rewards} \cdot T}{\text{reward period}}

So, the total rewards distributed until ee becomes

Rg=erptg(e)Ag(e)R_g = \sum_e rpt_g(e) \cdot A_g(e)

Of course, by the end of the reward period, RgR_g 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.

δ(rptu)=rptg(e)rptu(elast)\delta (rpt_u) = rpt_g(e) - rpt_u(e_{last})

When a user performs an action at event ee, their pending rewards can be calculated as:

pending rewards=δ(rptu)Au(e)\text{pending rewards} = \delta (rpt_u) \cdot A_u (e)

The user's total claimable rewards become:

Ru(e)=Ru(elast)+pending rewardsR_u(e) = R_u(e_{last}) + \text{pending rewards}

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

StatusDescription
SCHEDULEDWhen start time is in the future.
ACTIVEWhen current timestamp is in between start time and end time.
ENDEDWhen 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