Skip to content

vlSDT Developer Integration

This page is the developer reference for integrating with vlSDT (Vote Locked SDT), including the core staking contract, FeeDistributor, BoostRegistry, and BoostMarketplace. It covers essential concepts, quickstart, integration considerations, core interfaces, code examples, deep dives, and a FAQ.

Essential Concepts

  1. vlSDT: Non-transferable ERC20 representing staked SDT. 1 staked SDT = 1 vlSDT voting power (no decay). transfer(), transferFrom(), and approve() all revert.
  2. FeeDistributor: Distributes protocol fees to vlSDT holders in 7-day epochs. Uses checkpoint-based accounting to allocate tokens proportionally by balance at each epoch start.
  3. BoostRegistry: Enables flat (non-decaying) boost delegation with week-aligned expiry. Delegations stay constant until expiry, then drop to zero. Uses lazy checkpointing to walk expired delegation weeks on read.
  4. BoostMarketplace: On-chain order book with escrow for buying and selling boost delegations. Sell listings reserve seller capacity; buy offers escrow funds until accepted or cancelled.
  5. Unstake Request ID: Packed uint256 encoding (nonce << 160) | owner, allowing the contract to extract the owner address from any request ID.

Quickstart (TL;DR)

1. Resolve addresses

  • vlSDT contract address
  • FeeDistributor contract address
  • BoostRegistry address (available via vlSDT.BOOST_REGISTRY())
  • SDT token address (available via vlSDT.SDT())

2. Approve and stake

Approve vlSDT to spend SDT, then call stake(amount, recipient). Voting power is credited immediately.

3. Read balances

  • balanceOf(user) returns current voting power
  • balanceOfAt(user, timestamp) returns historical voting power
  • totalSupplyAt(timestamp) returns historical total supply

4. Claim fees

Call FeeDistributor.claim(user) to claim accumulated epoch rewards. Anyone can trigger a claim on behalf of any user (rewards always go to the user).

Integration Considerations

  • Non-transferable: transfer(), transferFrom(), and approve() all revert with NonTransferable(). vlSDT cannot be traded or used as collateral.
  • balanceOf() = current voting power: Reflects staked SDT minus any pending unstake amounts. balanceOfAt() provides historical values for any past timestamp.
  • Unstake reduces balance immediately: When a user calls unstake(), their vlSDT is burned at that moment. SDT is held in the contract for 8 weeks.
  • Delegation constrains unstake: Users cannot unstake more than balanceOf(user) - BoostRegistry.delegatedOut(user). Active delegations must expire or be waited out before unstaking those amounts.
  • claimable() is a view (no checkpoint): It may underreport compared to actual claim(), because claim() triggers a token checkpoint that distributes newly deposited tokens into epochs. Use claimable() as a lower-bound estimate.
  • Max 50 epochs per claim: claim() processes at most MAX_CLAIM_EPOCHS (50) epochs per call. Users who haven't claimed for a long time may need multiple claim() calls.
  • Max 20 epochs per token checkpoint: checkpointToken() processes at most MAX_CHECKPOINT_EPOCHS (20) epochs per call. If many epochs have passed without a checkpoint, call it multiple times.
  • Auto-checkpoint: If lastTokenTime + TOKEN_CHECKPOINT_DEADLINE < block.timestamp, any claim() call triggers a token checkpoint first. TOKEN_CHECKPOINT_DEADLINE is 1 day.
  • Zero supply epoch risk: Tokens allocated to epochs where totalSupply == 0 are permanently unrecoverable. While deposit() requires totalSupply > 0, edge cases remain if all users unstake after a deposit.
  • Fee-on-transfer tokens: Not supported in BoostMarketplace. The marketplace assumes exact transfer amounts for payment tokens.

Core Interfaces

IVlSDT

interface IVlSDT {
    struct UnstakeRequest {
        uint208 amount;
        uint48 deadline;
    }
 
    // Staking
    function stake(uint256 amount, address recipient) external;
    function unstake(uint256 amount) external returns (uint256 id);
    function cancelUnstake(uint256 id) external;
    function withdraw(uint256 id, address recipient) external;
 
    // Balances and checkpoints
    function balanceOf(address account) external view returns (uint256);
    function balanceOfAt(address user, uint256 timestamp) external view returns (uint256);
    function totalSupply() external view returns (uint256);
    function totalSupplyAt(uint256 timestamp) external view returns (uint256);
    function numCheckpoints(address user) external view returns (uint256);
    function checkpoints(address user, uint256 index) external view returns (uint48 timestamp, uint208 balance);
 
    // State
    function unstakeRequests(uint256 id) external view returns (uint208 amount, uint48 deadline);
    function nonce() external view returns (uint96);
 
    // Constants
    function SDT() external view returns (address);
    function UNSTAKE_DELAY() external view returns (uint256);
    function BOOST_REGISTRY() external view returns (address);
}

IVlFeeDistributor

interface IVlFeeDistributor {
    // Claims
    function claim(address user) external returns (uint256 amount);
    function claim(address[] calldata users) external returns (uint256[] memory amounts);
    function claimable(address user) external view returns (uint256 amount);
 
    // Token checkpointing
    function deposit(uint256 amount) external;
    function checkpointToken() external;
 
    // View
    function currentEpoch() external view returns (uint256 timestamp);
    function getTokensForEpoch(uint256 epoch) external view returns (uint256 tokens);
    function tokensPerEpoch(uint256 epoch) external view returns (uint256);
    function lastClaimedEpoch(address user) external view returns (uint256);
    function lastTokenTime() external view returns (uint256);
 
    // Constants
    function EPOCH_DURATION() external view returns (uint256);       // 7 days
    function MAX_CLAIM_EPOCHS() external view returns (uint256);     // 50
    function MAX_CHECKPOINT_EPOCHS() external view returns (uint256); // 20
    function TOKEN_CHECKPOINT_DEADLINE() external view returns (uint256); // 1 day
    function VLSDT() external view returns (IVlSDT);
    function REWARD_TOKEN() external view returns (IERC20);
}

IBoostRegistry

interface IBoostRegistry {
    function boost(address delegator, uint256 amount, uint256 endtime, address recipient) external;
    function delegableBalance(address account) external view returns (uint256);
    function delegatedOut(address user) external view returns (uint256);
    function delegatedIn(address user) external view returns (uint256);
    function setOperator(address operator, bool approved) external;
    function isOperator(address delegator, address operator) external view returns (bool);
    function operatorAndDelegableBalance(address delegator, address operator)
        external view returns (bool approved, uint256 available);
    function checkpointUser(address user) external;
    function MAX_DURATION_WEEKS() external view returns (uint256);  // 52
}

IBoostMarketplace

struct SellListing {
    address seller;
    address paymentToken;
    uint128 amount;        // vlSDT boost amount available
    uint128 filled;        // amount already sold
    uint128 pricePerWeek;  // price per vlSDT per week (payment token decimals)
    uint32  maxDuration;   // max weeks buyer can purchase
    uint64  expiry;        // 0 = no expiry
}
 
struct BuyOffer {
    address buyer;
    address paymentToken;
    uint128 amount;          // vlSDT boost amount wanted
    uint128 filled;          // amount already filled
    uint128 pricePerWeek;    // price per vlSDT per week
    uint32  duration;        // weeks of boost wanted
    uint64  expiry;          // required, funds locked until then
    uint256 escrowed;        // total payment token escrowed
    uint256 escrowRemaining; // remaining escrow in contract
}
 
interface IBoostMarketplace {
    // Sell listings
    function createListing(uint256 amount, uint256 pricePerWeek, uint256 maxDuration,
        address paymentToken, uint256 expiry) external returns (uint256 listingId);
    function fillListing(uint256 listingId, uint256 fillAmount, uint256 duration,
        uint256 maxTotalPayment) external;
    function cancelListing(uint256 listingId) external;
    function updateListing(uint256 listingId, uint256 newAmount, uint256 newPricePerWeek) external;
 
    // Buy offers
    function createOffer(uint256 amount, uint256 pricePerWeek, uint256 duration,
        address paymentToken, uint256 expiry) external returns (uint256 offerId);
    function acceptOffer(uint256 offerId, uint256 fillAmount, uint256 minTotalPayment) external;
    function cancelOffer(uint256 offerId) external;
    function updateOffer(uint256 offerId, uint256 newAmount, uint256 newPricePerWeek) external;
 
    // Views
    function getListing(uint256 listingId) external view returns (SellListing memory);
    function getOffer(uint256 offerId) external view returns (BuyOffer memory);
    function getListingRemaining(uint256 listingId) external view returns (uint256);
    function getOfferRemaining(uint256 offerId) external view returns (uint256);
    function committedBalance(address seller) external view returns (uint256);
}

Code Examples

1. Stake SDT

// Approve vlSDT to spend SDT
IERC20(sdt).approve(vlSDT, amount);
 
// Stake for yourself
IVlSDT(vlSDT).stake(amount, msg.sender);
// TypeScript with viem
import { parseEther } from "viem";
 
const amount = parseEther("1000");
 
// Approve
await walletClient.writeContract({
  address: sdtAddress,
  abi: erc20Abi,
  functionName: "approve",
  args: [vlSDTAddress, amount],
});
 
// Stake
await walletClient.writeContract({
  address: vlSDTAddress,
  abi: vlSDTAbi,
  functionName: "stake",
  args: [amount, account.address],
});

2. Stake on behalf of another address

// Stake SDT and credit voting power to a different recipient
IERC20(sdt).approve(vlSDT, amount);
IVlSDT(vlSDT).stake(amount, recipientAddress);

3. Read voting power

// Current voting power
uint256 currentPower = IVlSDT(vlSDT).balanceOf(user);
 
// Historical voting power at a specific timestamp
uint256 pastPower = IVlSDT(vlSDT).balanceOfAt(user, timestamp);
 
// Historical total supply
uint256 pastSupply = IVlSDT(vlSDT).totalSupplyAt(timestamp);
// TypeScript with viem
const power = await publicClient.readContract({
  address: vlSDTAddress,
  abi: vlSDTAbi,
  functionName: "balanceOf",
  args: [userAddress],
});
 
const historicalPower = await publicClient.readContract({
  address: vlSDTAddress,
  abi: vlSDTAbi,
  functionName: "balanceOfAt",
  args: [userAddress, BigInt(timestamp)],
});

4. Unstake request lifecycle

// Step 1: Request unstake (burns vlSDT immediately)
uint256 requestId = IVlSDT(vlSDT).unstake(amount);
 
// Step 2: Check request status
(uint208 unstakeAmount, uint48 deadline) = IVlSDT(vlSDT).unstakeRequests(requestId);
bool canWithdraw = block.timestamp >= deadline;
 
// Step 3a: Withdraw after 8 weeks
IVlSDT(vlSDT).withdraw(requestId, msg.sender);
 
// Step 3b: Or cancel to restore voting power
IVlSDT(vlSDT).cancelUnstake(requestId);

5. Decode unstake request ID

The request ID encodes the owner address, which allows the contract to verify ownership without a separate mapping:

// Extract owner from request ID
address owner = address(uint160(requestId));
 
// The nonce is in the upper bits
uint96 requestNonce = uint96(requestId >> 160);

6. Query and claim fees

// Check claimable amount (lower-bound estimate)
uint256 pending = IVlFeeDistributor(feeDistributor).claimable(user);
 
// Claim for a single user
uint256 claimed = IVlFeeDistributor(feeDistributor).claim(user);
 
// Batch claim for multiple users
address[] memory users = new address[](2);
users[0] = user1;
users[1] = user2;
uint256[] memory amounts = IVlFeeDistributor(feeDistributor).claim(users);
// TypeScript with viem
const claimable = await publicClient.readContract({
  address: feeDistributorAddress,
  abi: feeDistributorAbi,
  functionName: "claimable",
  args: [userAddress],
});
 
// Claim
await walletClient.writeContract({
  address: feeDistributorAddress,
  abi: feeDistributorAbi,
  functionName: "claim",
  args: [userAddress],
});

7. Delegate boost

// Step 1: Approve an operator (e.g., BoostMarketplace or a trusted contract)
IBoostRegistry(boostRegistry).setOperator(operatorAddress, true);
 
// Step 2: Create a delegation (operator or self)
// endtime must be week-aligned (Thursday 00:00 UTC), max 52 weeks out
uint256 endtime = (block.timestamp / 1 weeks + 4) * 1 weeks; // 4 weeks from now
IBoostRegistry(boostRegistry).boost(delegator, amount, endtime, recipient);

8. Check delegable and adjusted balances

// How much can this user still delegate?
uint256 available = IBoostRegistry(boostRegistry).delegableBalance(user);
 
// What is this user's effective balance (own - delegated out + delegated in)?
// adjustedBalance is available on the implementation, not the interface
// Use the individual components:
uint256 power = IVlSDT(vlSDT).balanceOf(user);
uint256 out = IBoostRegistry(boostRegistry).delegatedOut(user);
uint256 incoming = IBoostRegistry(boostRegistry).delegatedIn(user);
uint256 adjusted = power - out + incoming;
 
// Check operator status and delegable balance in one call
(bool approved, uint256 delegable) = IBoostRegistry(boostRegistry)
    .operatorAndDelegableBalance(delegator, operator);

9. Create and fill a sell listing

// Seller: approve BoostMarketplace as operator
IBoostRegistry(boostRegistry).setOperator(marketplace, true);
 
// Seller: create a listing
uint256 listingId = IBoostMarketplace(marketplace).createListing(
    1000e18,          // 1000 vlSDT boost
    1e15,             // price per vlSDT per week in payment token units
    12,               // max 12 weeks
    paymentToken,     // e.g., USDC
    0                 // no expiry
);
 
// Buyer: approve payment token, then fill
IERC20(paymentToken).approve(marketplace, type(uint256).max);
IBoostMarketplace(marketplace).fillListing(
    listingId,
    500e18,           // fill 500 vlSDT
    4,                // for 4 weeks
    maxTotalPayment   // slippage protection
);

10. Create and accept a buy offer

// Buyer: approve payment token and create offer (funds are escrowed)
IERC20(paymentToken).approve(marketplace, escrowAmount);
uint256 offerId = IBoostMarketplace(marketplace).createOffer(
    1000e18,          // want 1000 vlSDT boost
    1e15,             // price per vlSDT per week
    8,                // for 8 weeks
    paymentToken,     // payment token
    block.timestamp + 30 days  // offer expiry
);
 
// Seller: approve BoostMarketplace as operator, then accept
IBoostRegistry(boostRegistry).setOperator(marketplace, true);
IBoostMarketplace(marketplace).acceptOffer(
    offerId,
    500e18,           // fill 500 vlSDT
    minTotalPayment   // seller slippage protection
);

Deep Dives

Checkpoint data structure

vlSDT uses OpenZeppelin's Trace208 (from Checkpoints.sol) for both per-user and total supply history. Each checkpoint is a (uint48 key, uint208 value) pair, where the key is a timestamp and the value is the balance at that time.

Lookups use binary search over the sorted checkpoint array. balanceOfAt(user, timestamp) finds the latest checkpoint with key <= timestamp and returns its value. This gives O(log n) historical queries.

Unstake request ID encoding

Request IDs pack the owner address and a nonce into a single uint256:

|---- nonce (96 bits) ----|---- owner address (160 bits) ----|

This allows the contract to:

  • Extract the owner via address(uint160(id)) for authorization checks
  • Generate unique IDs without a separate mapping
  • Emit the ID in events for off-chain tracking

FeeDistributor token distribution

When deposit() or checkpointToken() is called, newly received tokens are distributed across epochs proportionally by time:

  1. Calculate how many tokens arrived since the last checkpoint
  2. For each epoch boundary crossed, allocate tokens proportionally to the time spent in that epoch
  3. Store the allocation in tokensPerEpoch[epochTimestamp]

When a user calls claim(), their share for each epoch is:

userShare = tokensPerEpoch[epoch] * balanceOfAt(user, epoch) / totalSupplyAt(epoch)

BoostRegistry lazy checkpointing

The BoostRegistry does not eagerly clean up expired delegations. Instead, it walks expired weeks during read operations:

  • delegatedOut(user) and delegatedIn(user) iterate through the user's weekly buckets, summing only non-expired entries
  • checkpointUser(user) explicitly walks and cleans up expired weeks
  • This amortizes gas costs across operations rather than requiring expensive cleanup transactions

Delegation amounts are stored per-week. A delegation of 100 vlSDT for 4 weeks creates entries in 4 weekly buckets. When each week's expiry passes, that bucket's contribution drops to zero on the next read.

BoostMarketplace escrow model

The marketplace uses two escrow mechanisms:

Sell listings use committedBalance:

  • When a seller creates a listing, the listing amount is added to their committedBalance
  • committedBalance is checked against delegableBalance to prevent over-commitment
  • When a listing is filled, the filled amount is subtracted from committedBalance
  • The actual delegation is created via BoostRegistry.boost() at fill time

Buy offers use token escrow:

  • When a buyer creates an offer, payment tokens are transferred into the marketplace contract
  • escrowRemaining tracks the remaining escrowed funds for each offer
  • When a seller accepts, payment is released from escrow to the seller (minus protocol fee)
  • When an offer is cancelled, remaining escrow is refunded to the buyer

Events Reference

vlSDT Events

EventParameters
Stakedcaller (indexed), recipient (indexed), amount
UnstakeRequestedid (indexed), owner (indexed), amount, deadline
UnstakeCancelledid (indexed), owner (indexed), amount
Withdrawnid (indexed), owner (indexed), recipient (indexed), amount

FeeDistributor Events

EventParameters
CheckpointTokentime (indexed), tokens
Claimeduser (indexed), amount, claimUpToEpoch
Depositeddepositor (indexed), amount
RecoveredemergencyAddress (indexed), token (indexed), amount
EmergencyAddressUpdatednewEmergencyAddress (indexed)
Killed(none)

BoostRegistry Events

EventParameters
Boostdelegator (indexed), recipient (indexed), amount, expiry
OperatorSetdelegator (indexed), operator (indexed), approved

BoostMarketplace Events

EventParameters
ListingCreatedlistingId (indexed), seller (indexed), amount, pricePerWeek, maxDuration, paymentToken
ListingCancelledlistingId (indexed)
ListingUpdatedlistingId (indexed), newAmount, newPricePerWeek
ListingFilledlistingId (indexed), buyer (indexed), amount, duration, totalPaid
OfferCreatedofferId (indexed), buyer (indexed), amount, pricePerWeek, duration, paymentToken
OfferUpdatedofferId (indexed), newAmount, newPricePerWeek
OfferCancelledofferId (indexed), refunded
OfferAcceptedofferId (indexed), seller (indexed), amount, totalPaid
FeeCollectedtoken (indexed), amount
CommittedBalanceUpdatedseller (indexed), oldBalance, newBalance, cause
OfferEscrowUpdatedofferId (indexed), oldEscrowRemaining, newEscrowRemaining

Errors Reference

vlSDT Errors

ErrorWhen
NonTransferable()Attempting transfer(), transferFrom(), or approve()
AmountMustBeGreaterThanZero()Staking or unstaking zero amount
AmountExceedsUsableBalance()Unstaking more than balanceOf - delegatedOut
RecipientCannotBeZeroAddress()Staking or withdrawing to zero address
Unauthorized()Caller is not the request owner (for cancel/withdraw)
UnstakeRequestNotFound()Request ID does not exist
UnstakeRequestNotExpired()Withdrawing before 8-week deadline

FeeDistributor Errors

ErrorWhen
ContractKilled()Calling functions on a killed contract
ZeroAddress()Invalid address parameter
ZeroAmount()Depositing zero amount
CannotRecoverRewardToken()Attempting to recover the reward token
NoStakers()Depositing when totalSupply == 0

BoostRegistry Errors

ErrorWhen
ZERO_ADDRESS()Zero address for recipient or operator
ZERO_AMOUNT()Delegating zero amount
INVALID_EXPIRY()Expiry not week-aligned or in the past
INSUFFICIENT_BALANCE()Delegating more than delegableBalance
NOT_OPERATOR()Caller is not the delegator or an approved operator

BoostMarketplace Errors

ErrorWhen
ZERO_AMOUNT()Zero listing/offer amount
ZERO_ADDRESS()Zero payment token address
ZERO_PRICE()Zero price per week
INVALID_DURATION()Duration is zero or exceeds max
INVALID_EXPIRY()Expiry in the past
LISTING_NOT_FOUND()Listing ID does not exist
LISTING_EXPIRED()Filling an expired listing
OFFER_NOT_FOUND()Offer ID does not exist
OFFER_EXPIRED()Accepting an expired offer
INSUFFICIENT_AMOUNT()Fill amount exceeds remaining capacity
BELOW_MINIMUM()Amount below minOrderAmount or minFillAmount
NOT_OWNER()Caller is not the listing/offer owner
NOT_OPERATOR()Seller has not approved marketplace as operator
MAX_PAYMENT_EXCEEDED()Buyer's maxTotalPayment exceeded (listing fill)
MIN_PAYMENT_NOT_MET()Seller's minTotalPayment not met (offer accept)
EXCEEDS_AVAILABLE_CAPACITY()Listing amount exceeds delegable balance minus committed
EXCEEDS_REGISTRY_MAX_DURATION()Duration exceeds BoostRegistry max (52 weeks)
VALUE_TOO_LARGE()Value exceeds packed storage limits
ZERO_PAYMENT()Computed payment is zero

FAQ

Why does claimable() return less than the actual claim() amount?

claimable() is a pure view function that does not trigger a token checkpoint. If new tokens have been deposited but checkpointToken() hasn't been called yet, claimable() won't account for them. The claim() function auto-triggers a checkpoint when lastTokenTime + 1 day < block.timestamp, so it may distribute additional tokens.

Can I stake on behalf of another user?

Yes. stake(amount, recipient) allows the caller to provide SDT while the recipient receives the voting power. The caller must have approved vlSDT to spend their SDT.

What happens if I try to unstake while I have active delegations?

The contract checks balanceOf(user) - BoostRegistry.delegatedOut(user) as the maximum unstakeable amount. If your delegations exceed your remaining balance after unstake, the transaction reverts with AmountExceedsUsableBalance().

Is vlSDT an ERC20?

Partially. It implements the ERC20 interface for name(), symbol(), decimals(), totalSupply(), and balanceOf(). However, transfer(), transferFrom(), and approve() all revert with NonTransferable(). allowance() always returns 0.

How does checkpoint timing affect fee claims?

  • Checkpoints happen on stake, unstake, and cancel operations
  • Your balance at the epoch start determines your share for that epoch
  • If you stake in the middle of an epoch, you earn nothing for that epoch but start earning from the next one
  • If you unstake in the middle of an epoch, you still earn for that epoch (your checkpoint at epoch start still shows the pre-unstake balance)

What is the maximum delegation duration?

MAX_DURATION_WEEKS is 52 weeks (1 year). Delegation endtimes must be week-aligned (Thursday 00:00 UTC) and cannot exceed 52 weeks from the current timestamp.

How does the BoostMarketplace interact with the BoostRegistry?

The marketplace is an authorized operator on the BoostRegistry. When a listing is filled or an offer is accepted, the marketplace calls BoostRegistry.boost() to create the delegation from the seller to the buyer. Sellers must first call BoostRegistry.setOperator(marketplace, true) to authorize the marketplace.

What happens to committed balance if a listing isn't fully filled?

The unfilled portion of a listing continues to count against the seller's committedBalance, reducing their available delegation capacity. Sellers can cancel the listing to release the committed balance, or update the listing amount downward.