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
- vlSDT: Non-transferable ERC20 representing staked SDT. 1 staked SDT = 1 vlSDT voting power (no decay).
transfer(),transferFrom(), andapprove()all revert. - 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.
- 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.
- 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.
- Unstake Request ID: Packed
uint256encoding(nonce << 160) | owner, allowing the contract to extract the owner address from any request ID.
Quickstart (TL;DR)
1. Resolve addresses
vlSDTcontract addressFeeDistributorcontract addressBoostRegistryaddress (available viavlSDT.BOOST_REGISTRY())SDTtoken address (available viavlSDT.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 powerbalanceOfAt(user, timestamp)returns historical voting powertotalSupplyAt(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(), andapprove()all revert withNonTransferable(). 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 actualclaim(), becauseclaim()triggers a token checkpoint that distributes newly deposited tokens into epochs. Useclaimable()as a lower-bound estimate.- Max 50 epochs per claim:
claim()processes at mostMAX_CLAIM_EPOCHS(50) epochs per call. Users who haven't claimed for a long time may need multipleclaim()calls. - Max 20 epochs per token checkpoint:
checkpointToken()processes at mostMAX_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, anyclaim()call triggers a token checkpoint first.TOKEN_CHECKPOINT_DEADLINEis 1 day. - Zero supply epoch risk: Tokens allocated to epochs where
totalSupply == 0are permanently unrecoverable. Whiledeposit()requirestotalSupply > 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:
- Calculate how many tokens arrived since the last checkpoint
- For each epoch boundary crossed, allocate tokens proportionally to the time spent in that epoch
- 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)anddelegatedIn(user)iterate through the user's weekly buckets, summing only non-expired entriescheckpointUser(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 committedBalanceis checked againstdelegableBalanceto 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
escrowRemainingtracks 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
| Event | Parameters |
|---|---|
Staked | caller (indexed), recipient (indexed), amount |
UnstakeRequested | id (indexed), owner (indexed), amount, deadline |
UnstakeCancelled | id (indexed), owner (indexed), amount |
Withdrawn | id (indexed), owner (indexed), recipient (indexed), amount |
FeeDistributor Events
| Event | Parameters |
|---|---|
CheckpointToken | time (indexed), tokens |
Claimed | user (indexed), amount, claimUpToEpoch |
Deposited | depositor (indexed), amount |
Recovered | emergencyAddress (indexed), token (indexed), amount |
EmergencyAddressUpdated | newEmergencyAddress (indexed) |
Killed | (none) |
BoostRegistry Events
| Event | Parameters |
|---|---|
Boost | delegator (indexed), recipient (indexed), amount, expiry |
OperatorSet | delegator (indexed), operator (indexed), approved |
BoostMarketplace Events
| Event | Parameters |
|---|---|
ListingCreated | listingId (indexed), seller (indexed), amount, pricePerWeek, maxDuration, paymentToken |
ListingCancelled | listingId (indexed) |
ListingUpdated | listingId (indexed), newAmount, newPricePerWeek |
ListingFilled | listingId (indexed), buyer (indexed), amount, duration, totalPaid |
OfferCreated | offerId (indexed), buyer (indexed), amount, pricePerWeek, duration, paymentToken |
OfferUpdated | offerId (indexed), newAmount, newPricePerWeek |
OfferCancelled | offerId (indexed), refunded |
OfferAccepted | offerId (indexed), seller (indexed), amount, totalPaid |
FeeCollected | token (indexed), amount |
CommittedBalanceUpdated | seller (indexed), oldBalance, newBalance, cause |
OfferEscrowUpdated | offerId (indexed), oldEscrowRemaining, newEscrowRemaining |
Errors Reference
vlSDT Errors
| Error | When |
|---|---|
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
| Error | When |
|---|---|
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
| Error | When |
|---|---|
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
| Error | When |
|---|---|
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.