Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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.

Sequence diagram of a SparkVault deposit, a TAKER_ROLE take that deploys assets into a yield strategy, and a later withdraw, with chi compounding at vsr while assets are deployed externally

Supported Networks and Token Addresses

NetworkVaultTokenAddress
EthereumSpark Savings USDCspUSDC0x28B3a8fb53B741A8Fd78c0fb9A6B2393d896a43d
EthereumSpark Savings USDTspUSDT0xe2e7a17dFf93280dec073C995595155283e3C372
EthereumSpark Savings ETHspETH0xfE6eb3b609a7C8352A241f7F3A21CEA4e9209B8f
EthereumSpark Savings PYUSDspPYUSD0x80128DbB9f07b93DDE62A6daeadb69ED14a7D354
AvalancheSpark Savings USDCspUSDC0x28B3a8fb53B741A8Fd78c0fb9A6B2393d896a43d
RobinhoodSpark Savings USDGspUSDG0xde770c84FE66E063336b31737cFE9790f18c4087

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 an ERC1967Proxy)
  • Source: src/SparkVault.sol
  • Interface: src/ISparkVault.sol
  • Inheritance: AccessControlEnumerableUpgradeable, UUPSUpgradeable, ISparkVault (IERC20Permit + IERC4626 + IAccessControlEnumerable)
  • Solidity: ^0.8.25

Constants

NameValuePurpose
RAY1e27Fixed-point precision for chi and vsr.
MAX_VSR1.000000021979553151239153027e27Per-second rate equivalent to 100% APY — hard ceiling.
SETTER_ROLEkeccak256("SETTER_ROLE")Role identifier.
TAKER_ROLEkeccak256("TAKER_ROLE")Role identifier.
PERMIT_TYPEHASHEIP-712 typehash for Permit(...)Used by permit.
version"1"Contract version string, also used in the EIP-712 domain.

Storage

VariableTypeMeaning
assetaddressUnderlying ERC-20 (USDC, USDT, WETH, …).
decimalsuint8Mirrors IERC20Metadata(asset).decimals().
namestringShare token name, e.g. "Spark Savings USDC".
symbolstringShare token symbol, e.g. "spUSDC".
rhouint64Unix timestamp of the last drip (rate accumulation step).
chiuint192Stored rate accumulator at rho, in RAY. shares * chi / RAY = assets at rho.
vsruint256Per-second rate, in RAY. 1e27 means "no yield".
minVsruint256Lower bound enforced on setVsr. Must be >= RAY.
maxVsruint256Upper bound enforced on setVsr. Must be <= MAX_VSR.
depositCapuint256Total-asset cap. Deposits revert if totalAssets() + assets > depositCap.
totalSupplyuint256Total share supply.
balanceOfmapping(address => uint256)Share balances.
allowancemapping(address => mapping(...))ERC-20 allowances over shares.
noncesmapping(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, decimals from the underlying token;
  • chi = RAY, rho = block.timestamp, vsr = minVsr = maxVsr = RAY;
  • grants DEFAULT_ADMIN_ROLE to admin.

In this state the vault accrues no yield and rejects all deposits (depositCap == 0). To make the vault operational, the admin must, in order:

  1. setVsrBounds(minVsr_, maxVsr_) — open the rate-setting band.
  2. grantRole(SETTER_ROLE, …) and have the setter call setVsr(newVsr).
  3. setDepositCap(newCap) to allow deposits.
  4. grantRole(TAKER_ROLE, …) — typically the Spark Liquidity Layer — to enable take().

Roles and Access Control

The contract uses AccessControlEnumerableUpgradeable with three roles. DEFAULT_ADMIN_ROLE is the admin of all three (default behavior of AccessControl).

Flowchart of SparkVault roles: DEFAULT_ADMIN_ROLE authorizes UUPS upgrades, sets caps and VSR bounds, and grants/revokes SETTER_ROLE and TAKER_ROLE; SETTER_ROLE sets the VSR within bounds; TAKER_ROLE calls take to pull idle liquidity and is blocked from depositing or receiving shares; users deposit, mint, withdraw, and redeem

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, and DEFAULT_ADMIN_ROLE itself (standard AccessControl semantics).

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):

NetworkAdmin address
Ethereum (all vaults)0x3300f198988e4C9C63F75dF86De36421f06af8c4
Avalanche0x7566DEbC906C17338524A414343fA61BcA26A843

SETTER_ROLE

  • Calls setVsr(newVsr) to update the rate, constrained to the [minVsr, maxVsr] band currently set by the admin. setVsr always calls drip() first so the rate change applies only from the moment of the call.

TAKER_ROLE

  • Calls take(value) to pull value units of asset out of the vault, e.g. to deploy into yield via the Spark Liquidity Layer.
  • Returning assets is just a plain ERC-20 transfer to the vault address — there is no symmetric give function.
  • Accounts holding TAKER_ROLE cannot call deposit/mint and cannot be set as the receiver of 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

FunctionNotes
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

FunctionNotes
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 / RAY

Both chi and vsr are in RAY (1e27). vsr == 1e27 means 0% APY. vsr == MAX_VSR is the 100% APY ceiling.

Functions

FunctionNotes
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

FunctionCallerEffect
setVsrBounds(uint256 min, uint256 max)DEFAULT_ADMIN_ROLERequires min >= RAY, max <= MAX_VSR, min <= max. Emits VsrBoundsSet.
setVsr(uint256 newVsr)SETTER_ROLERequires minVsr <= newVsr <= maxVsr. Always calls drip() first. Emits VsrSet.
setDepositCap(uint256 newCap)DEFAULT_ADMIN_ROLENo 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 at vsr.
  • asset.balanceOf(vault) — the currently idle balance available for withdrawals.
  • assetsOutstanding() — the difference: what the taker is implicitly liable for if accounted at the current chi.

_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);
  • signature is accepted as either a 65-byte (r, s, v) packed ECDSA signature or, if the owner is a contract, as an ERC-1271 isValidSignature(digest, signature) payload.
  • The EIP-712 domain is (name, version, chainId, address(this)), with name taken from storage and version always "1". DOMAIN_SEPARATOR() is recomputed from the current block.chainid on 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_ROLE can upgrade (_authorizeUpgrade is gated by onlyRole(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 by deposit / mint): the asset transferFrom (_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 by withdraw / redeem): the asset transfer (_pushAsset) is the last state change. Reentering after the transfer is equivalent to a fresh call after the withdrawal completed.
  • take: the asset transfer is 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

EventTrigger
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 / RoleAdminChangedInherited from AccessControl.
Upgraded(address indexed implementation)Inherited from ERC1967Utils on each UUPS upgrade.

Errors (revert strings)

StringWhere
SparkVault/vsr-too-lowsetVsrBounds (minVsr_ < RAY) and setVsr (newVsr < minVsr).
SparkVault/vsr-too-highsetVsrBounds (maxVsr_ > MAX_VSR) and setVsr (newVsr > maxVsr).
SparkVault/min-vsr-gt-max-vsrsetVsrBounds (minVsr_ > maxVsr_).
SparkVault/invalid-addresstransfer / transferFrom / _mint reject address(0) and address(this).
SparkVault/insufficient-balancetransfer, transferFrom, _burn.
SparkVault/insufficient-allowancetransferFrom, _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-expiredpermit after deadline.
SparkVault/invalid-ownerpermit with owner == address(0).
SparkVault/invalid-permitpermit 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, and previewWithdraw / previewRedeem revert if asset.balanceOf(vault) is short of the requested asset amount, even when the user's assetsOf is larger. For redemptions above the idle balance, use the Savings Vault Intents flow.
  • totalAssets()asset.balanceOf(vault). The first is share-implied and grows at vsr. The second is the on-chain idle balance, which drops to zero whenever take() 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 advance chi to the current timestamp. State-mutating ERC-4626 entry points already call drip internally; 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 referral argument is emitted in Referral but is not stored on-chain. Indexers must capture it from the logs.

Additional Resources