Spark Savings Vaults V2
Overview
Spark Savings Vaults V2 is an ERC-4626 yield-bearing vault that earns through a continuous per-second rate accumulator (the Vault Savings Rate, vsr). Yield is not generated inside the vault. Instead, a permissioned TAKER_ROLE — granted to the Spark Liquidity Layer — pulls idle liquidity, deploys it into yield-bearing strategies, and pushes the proceeds back to the vault. Share value increases continuously based on vsr, regardless of where the underlying assets currently sit.
The implementation lives in src/SparkVault.sol and is deployed behind an ERC-1967 UUPS proxy. The contract is a fork of sUSDS with Sky's DSS hooks removed, OpenZeppelin AccessControlEnumerable replacing wards/rely/deny, and the addition of the TAKER_ROLE and take() mechanism.
Supported Networks and Token Addresses
| Network | Vault | Token | Address |
|---|---|---|---|
| Ethereum | Spark Savings USDC | spUSDC | 0x28B3a8fb53B741A8Fd78c0fb9A6B2393d896a43d |
| Ethereum | Spark Savings USDT | spUSDT | 0xe2e7a17dFf93280dec073C995595155283e3C372 |
| Ethereum | Spark Savings ETH | spETH | 0xfE6eb3b609a7C8352A241f7F3A21CEA4e9209B8f |
| Ethereum | Spark Savings PYUSD | spPYUSD | 0x80128DbB9f07b93DDE62A6daeadb69ED14a7D354 |
| Avalanche | Spark Savings USDC | spUSDC | 0x28B3a8fb53B741A8Fd78c0fb9A6B2393d896a43d |
| Robinhood | Spark Savings USDG | spUSDG | 0xde770c84FE66E063336b31737cFE9790f18c4087 |
The "Vault" column above is the on-chain name(); the "Token" column is the on-chain symbol(). Each vault is named after its denomination, not its underlying asset's symbol: the ETH vault uses WETH as the underlying (not native ETH), but its token is spETH with name() == "Spark Savings ETH" — never "spWETH" or "Spark Savings WETH".
Contract Details
- Contract:
SparkVault(UUPS upgradeable, behind anERC1967Proxy) - Source:
src/SparkVault.sol - Interface:
src/ISparkVault.sol - Inheritance:
AccessControlEnumerableUpgradeable,UUPSUpgradeable,ISparkVault(IERC20Permit + IERC4626 + IAccessControlEnumerable) - Solidity:
^0.8.25
Constants
| Name | Value | Purpose |
|---|---|---|
RAY | 1e27 | Fixed-point precision for chi and vsr. |
MAX_VSR | 1.000000021979553151239153027e27 | Per-second rate equivalent to 100% APY — hard ceiling. |
SETTER_ROLE | keccak256("SETTER_ROLE") | Role identifier. |
TAKER_ROLE | keccak256("TAKER_ROLE") | Role identifier. |
PERMIT_TYPEHASH | EIP-712 typehash for Permit(...) | Used by permit. |
version | "1" | Contract version string, also used in the EIP-712 domain. |
Storage
| Variable | Type | Meaning |
|---|---|---|
asset | address | Underlying ERC-20 (USDC, USDT, WETH, …). |
decimals | uint8 | Mirrors IERC20Metadata(asset).decimals(). |
name | string | Share token name, e.g. "Spark Savings USDC". |
symbol | string | Share token symbol, e.g. "spUSDC". |
rho | uint64 | Unix timestamp of the last drip (rate accumulation step). |
chi | uint192 | Stored rate accumulator at rho, in RAY. shares * chi / RAY = assets at rho. |
vsr | uint256 | Per-second rate, in RAY. 1e27 means "no yield". |
minVsr | uint256 | Lower bound enforced on setVsr. Must be >= RAY. |
maxVsr | uint256 | Upper bound enforced on setVsr. Must be <= MAX_VSR. |
depositCap | uint256 | Total-asset cap. Deposits revert if totalAssets() + assets > depositCap. |
totalSupply | uint256 | Total share supply. |
balanceOf | mapping(address => uint256) | Share balances. |
allowance | mapping(address => mapping(...)) | ERC-20 allowances over shares. |
nonces | mapping(address => uint256) | EIP-712 permit nonces per owner. |
Initial state after initialize
initialize(address asset, string name, string symbol, address admin) sets:
asset,name,symbol,decimalsfrom the underlying token;chi = RAY,rho = block.timestamp,vsr = minVsr = maxVsr = RAY;- grants
DEFAULT_ADMIN_ROLEtoadmin.
In this state the vault accrues no yield and rejects all deposits (depositCap == 0). To make the vault operational, the admin must, in order:
setVsrBounds(minVsr_, maxVsr_)— open the rate-setting band.grantRole(SETTER_ROLE, …)and have the setter callsetVsr(newVsr).setDepositCap(newCap)to allow deposits.grantRole(TAKER_ROLE, …)— typically the Spark Liquidity Layer — to enabletake().
Roles and Access Control
The contract uses AccessControlEnumerableUpgradeable with three roles. DEFAULT_ADMIN_ROLE is the admin of all three (default behavior of AccessControl).
DEFAULT_ADMIN_ROLE
- Authorizes UUPS upgrades (
_authorizeUpgrade). - Sets the vault deposit cap (
setDepositCap). - Sets the VSR bounds (
setVsrBounds). - Grants and revokes
SETTER_ROLE,TAKER_ROLE, andDEFAULT_ADMIN_ROLEitself (standardAccessControlsemantics).
Current DEFAULT_ADMIN_ROLE holders (sole holder per chain, verified on-chain in June 2026; query getRoleMember(DEFAULT_ADMIN_ROLE, 0) for the live value):
| Network | Admin address |
|---|---|
| Ethereum (all vaults) | 0x3300f198988e4C9C63F75dF86De36421f06af8c4 |
| Avalanche | 0x7566DEbC906C17338524A414343fA61BcA26A843 |
SETTER_ROLE
- Calls
setVsr(newVsr)to update the rate, constrained to the[minVsr, maxVsr]band currently set by the admin.setVsralways callsdrip()first so the rate change applies only from the moment of the call.
TAKER_ROLE
- Calls
take(value)to pullvalueunits ofassetout of the vault, e.g. to deploy into yield via the Spark Liquidity Layer. - Returning assets is just a plain ERC-20
transferto the vault address — there is no symmetricgivefunction. - Accounts holding
TAKER_ROLEcannot calldeposit/mintand cannot be set as thereceiverof a deposit/mint (SparkVault/taker-cannot-deposit).
ERC-4626 Surface
In what follows, assets means the underlying token (USDC, USDT, WETH, …) and shares means the vault token (spUSDC, spUSDT, spETH, …).
Mutating
| Function | Notes |
|---|---|
deposit(uint256 assets, address receiver) returns (uint256 shares) | Calls drip(), pulls assets from msg.sender, mints shares = assets * RAY / chi to receiver. |
deposit(uint256 assets, address receiver, uint16 referral) | Same as above, plus emits Referral(referral, receiver, assets, shares). |
mint(uint256 shares, address receiver) returns (uint256 assets) | Calls drip(), pulls assets = ceil(shares * chi / RAY) from msg.sender, mints shares to receiver. |
mint(uint256 shares, address receiver, uint16 referral) | Same as above, plus emits Referral. |
withdraw(uint256 assets, address receiver, address owner) | Calls drip(), burns shares = ceil(assets * RAY / chi) from owner, pushes assets to receiver. |
redeem(uint256 shares, address receiver, address owner) | Calls drip(), burns shares from owner, pushes assets = shares * chi / RAY to receiver. |
withdraw and redeem revert with SparkVault/insufficient-liquidity if the vault's asset.balanceOf(self) is less than the assets being pushed, even if the user is otherwise entitled.
View
| Function | Notes |
|---|---|
totalAssets() | totalSupply * nowChi() / RAY. Theoretical, not the on-chain asset balance. |
convertToShares(assets) | assets * RAY / nowChi(). |
convertToAssets(shares) | shares * nowChi() / RAY. |
maxDeposit(addr) | max(0, depositCap - totalAssets()). |
maxMint(addr) | Shares equivalent of maxDeposit; returns type(uint256).max for "infinite" caps to avoid overflow. |
maxWithdraw(owner) | min(asset.balanceOf(self), assetsOf(owner)). |
maxRedeem(owner) | min(asset.balanceOf(self) * RAY / nowChi(), balanceOf[owner]) — rounded down (intentional, see comments). |
previewDeposit(assets) | Equivalent to convertToShares. |
previewMint(shares) | ceil(shares * nowChi() / RAY). |
previewWithdraw(assets) | ceil(assets * RAY / nowChi()). Reverts SparkVault/insufficient-liquidity if asset.balanceOf(self) < assets. |
previewRedeem(shares) | Equivalent to convertToAssets. Reverts SparkVault/insufficient-liquidity if vault liquidity is short of the result. |
The revert behavior of previewRedeem / previewWithdraw differs from the ERC-4626 reference implementations and is the main integration footgun. If you need a non-reverting estimate, use convertToAssets / convertToShares and check asset.balanceOf(vault) yourself, or use the request-based Savings Vault Intents flow when redemption would exceed idle liquidity.
Rate Accumulation
The vault exposes the Sky-style "chi/rho/dsr" rate accumulator with the rate variable renamed to vsr (Vault Savings Rate).
Formula
chi_new = rpow(vsr, block.timestamp - rho) * chi_old / RAYBoth chi and vsr are in RAY (1e27). vsr == 1e27 means 0% APY. vsr == MAX_VSR is the 100% APY ceiling.
Functions
| Function | Notes |
|---|---|
drip() returns (uint256 nChi) | Public. Idempotent within a block. Updates chi/rho and emits Drip(nChi, diff) where diff is the asset-denominated increase since the last drip. |
nowChi() returns (uint256) | Pure-view version of drip — returns what chi would be if drip were called now, without mutating state. |
assetsOf(owner) returns (uint256) | convertToAssets(balanceOf[owner]) — owner's current asset-denominated position. |
assetsOutstanding() returns (uint256) | max(0, totalAssets() - asset.balanceOf(self)) — assets deployed externally and not yet returned. |
Bounds and the setter
| Function | Caller | Effect |
|---|---|---|
setVsrBounds(uint256 min, uint256 max) | DEFAULT_ADMIN_ROLE | Requires min >= RAY, max <= MAX_VSR, min <= max. Emits VsrBoundsSet. |
setVsr(uint256 newVsr) | SETTER_ROLE | Requires minVsr <= newVsr <= maxVsr. Always calls drip() first. Emits VsrSet. |
setDepositCap(uint256 newCap) | DEFAULT_ADMIN_ROLE | No drip; affects only new deposits. Emits DepositCapSet. |
Taker Mechanism (take)
function take(uint256 value) external onlyRole(TAKER_ROLE);Transfers value units of asset from the vault to msg.sender and emits Take(msg.sender, value). There is no symmetric give — returning assets is just an ordinary ERC-20 transfer to the vault.
Because take does not adjust any internal accounting, totalAssets() is unaffected by removing or returning liquidity. The vault's view of the world is:
totalAssets()— the share-implied asset total, growing per second atvsr.asset.balanceOf(vault)— the currently idle balance available for withdrawals.assetsOutstanding()— the difference: what the taker is implicitly liable for if accounted at the currentchi.
_pushAsset (used by take, redeem, withdraw) requires value <= asset.balanceOf(self) and reverts with SparkVault/insufficient-liquidity otherwise. The taker cannot drain more than the vault currently holds, and users cannot withdraw past the idle balance.
ERC-20 Surface (shares)
Shares are first-class ERC-20 tokens. transfer and transferFrom reject transfers to address(0) and address(this). approve sets the allowance to the exact value passed and always returns true; there are no allowance-race mitigations (no increaseAllowance/decreaseAllowance) beyond signature-based approvals via permit.
EIP-712 / permit
function permit(address owner, address spender, uint256 value, uint256 deadline, bytes memory signature) public;
function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external;
function DOMAIN_SEPARATOR() external view returns (bytes32);
function nonces(address owner) external view returns (uint256);signatureis accepted as either a 65-byte(r, s, v)packed ECDSA signature or, if theowneris a contract, as an ERC-1271isValidSignature(digest, signature)payload.- The EIP-712 domain is
(name, version, chainId, address(this)), withnametaken from storage andversionalways"1".DOMAIN_SEPARATOR()is recomputed from the currentblock.chainidon each call (no cache, no replay risk on hard-forked chains). - Nonces are monotonic per
owner.
Upgradeability
- ERC-1967 UUPS proxy. The proxy is
openzeppelin-contracts/proxy/ERC1967/ERC1967Proxy. getImplementation()returns the current implementation slot.- Only
DEFAULT_ADMIN_ROLEcan upgrade (_authorizeUpgradeis gated byonlyRole(DEFAULT_ADMIN_ROLE)). - The implementation constructor calls
_disableInitializers(). The implementation itself cannot be initialized directly.
Reentrancy Ordering
The vault does not use a global reentrancy guard. Instead, its external-call ordering is structured so that reentering is observationally equivalent to a separate top-level call:
_mint(entered bydeposit/mint): the assettransferFrom(_pullAsset) is the first state change. A reentrancy on the asset before the transfer is equivalent to a fresh call before the deposit started._burn(entered bywithdraw/redeem): the assettransfer(_pushAsset) is the last state change. Reentering after the transfer is equivalent to a fresh call after the withdrawal completed.take: the assettransferis the only external interaction and is the first state change in the call.
Integrators that wrap these calls in other ERC-20 interactions on the same asset should still be cautious about callback tokens (e.g. ERC-777 or transfer-hook tokens). The vault is not designed for assets that execute caller code on transfer.
Events
| Event | Trigger |
|---|---|
Transfer(address indexed from, address indexed to, uint256 value) | Share transfers, including from = 0 on mint and to = 0 on burn. |
Approval(address indexed owner, address indexed spender, uint256 value) | approve and permit. |
Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares) | deposit / mint. |
Withdraw(address indexed sender, address indexed receiver, address indexed owner, uint256 assets, uint256 shares) | withdraw / redeem. |
Referral(uint16 indexed referral, address indexed owner, uint256 assets, uint256 shares) | Referral overloads of deposit / mint. |
Drip(uint256 chi, uint256 diff) | Every drip() call. diff is the asset-denominated change since the previous chi. |
DepositCapSet(uint256 oldCap, uint256 newCap) | setDepositCap. |
VsrBoundsSet(uint256 oldMinVsr, uint256 oldMaxVsr, uint256 newMinVsr, uint256 newMaxVsr) | setVsrBounds. |
VsrSet(address indexed sender, uint256 oldVsr, uint256 newVsr) | setVsr. |
Take(address indexed to, uint256 value) | take. |
RoleGranted / RoleRevoked / RoleAdminChanged | Inherited from AccessControl. |
Upgraded(address indexed implementation) | Inherited from ERC1967Utils on each UUPS upgrade. |
Errors (revert strings)
| String | Where |
|---|---|
SparkVault/vsr-too-low | setVsrBounds (minVsr_ < RAY) and setVsr (newVsr < minVsr). |
SparkVault/vsr-too-high | setVsrBounds (maxVsr_ > MAX_VSR) and setVsr (newVsr > maxVsr). |
SparkVault/min-vsr-gt-max-vsr | setVsrBounds (minVsr_ > maxVsr_). |
SparkVault/invalid-address | transfer / transferFrom / _mint reject address(0) and address(this). |
SparkVault/insufficient-balance | transfer, transferFrom, _burn. |
SparkVault/insufficient-allowance | transferFrom, _burn. |
SparkVault/taker-cannot-deposit | _mint when msg.sender or receiver holds TAKER_ROLE. |
SparkVault/deposit-cap-exceeded | _mint when totalAssets() + assets > depositCap. |
SparkVault/insufficient-liquidity | _pushAsset (take, withdraw, redeem) and previewWithdraw / previewRedeem when the vault's idle balance is short. |
SparkVault/permit-expired | permit after deadline. |
SparkVault/invalid-owner | permit with owner == address(0). |
SparkVault/invalid-permit | permit with an invalid ECDSA signature or failing ERC-1271 check. |
Access-control failures revert with the OpenZeppelin custom error AccessControlUnauthorizedAccount(address, bytes32).
Integration Notes
- Idle liquidity is the redemption ceiling.
withdraw,redeem, andpreviewWithdraw/previewRedeemrevert ifasset.balanceOf(vault)is short of the requested asset amount, even when the user'sassetsOfis larger. For redemptions above the idle balance, use the Savings Vault Intents flow. totalAssets()≠asset.balanceOf(vault). The first is share-implied and grows atvsr. The second is the on-chain idle balance, which drops to zero whenevertake()is called and recovers only when the taker returns assets via a plain ERC-20 transfer.- No pause / freeze / recovery primitive. The contract has no pause, no kill, no recovery flag. Admin levers to slow deposits and outflows are: setting
depositCap = 0(blocks new deposits), or upgrading the implementation via UUPS. Withdrawals from existing share balances cannot be administratively halted while idle liquidity exists. drip()is permissionless. Any caller can advancechito the current timestamp. State-mutating ERC-4626 entry points already calldripinternally; integrations rarely need to call it explicitly.- No rebasing. Shares are non-rebasing. Per-user balances are fixed in share units; per-share asset value increases through
chi. - Referrals are an event-only feature. The
uint16 referralargument is emitted inReferralbut is not stored on-chain. Indexers must capture it from the logs.