Spark ALM Controller
Source code: sparkdotfi/spark-alm-controller
Overview
The Spark ALM Controller is the onchain execution layer of the Spark Liquidity Layer. It is a small set of contracts that:
- Hold custody of all SLL capital in a single proxy (
ALMProxy). - Route every action through a controller (
MainnetControlleron Ethereum,ForeignControlleron each supported L2). - Enforce per-integration whitelists and exposure caps via a stateful
RateLimitscontract keyed bykeccak256identifiers. - Provide an emergency revocation path via the
FREEZERrole that does not require a governance vote.
The same controller pattern is also used in two narrower contexts:
ALMProxyFreezable, a variant of the proxy used where the relayers themselves are granted the proxy'sCONTROLLERrole directly. This is intended for lower-criticality state where governance accepts a wider attack surface in exchange for operational simplicity, and where theFREEZERquick-revoke path is still preserved.OTCBufferandWEETHModule, two purpose-built helper contracts described in their own sections below.
Architecture
Every transaction originates from an offchain RELAYER, lands on the controller, is rate-limited and parameter-checked, and is then executed by the proxy that actually holds the funds. The controller calls triggerRateLimitDecrease / triggerRateLimitIncrease on RateLimits, and doCall / doCallWithValue / doDelegateCall on the ALMProxy, which performs the approved interaction with the external venue (Sky / PSM / AAVE / Morpho / Curve / Uniswap V4 / OTC / …).
When minting USDS via the Sky allocation system, funds are never held by the controller — they are pulled into the proxy and stay there until a subsequent relayer action moves them. The controller decrements LIMIT_USDS_MINT, instructs the proxy to draw from the Sky allocator vault (minting USDS to the allocator buffer), and then transfers that USDS from the buffer into the proxy.
Core contracts
ALMProxy
src/ALMProxy.sol. Holds custody of all SLL capital. Stateless except for OpenZeppelin AccessControl. Only addresses with the CONTROLLER role can invoke doCall, doCallWithValue, or doDelegateCall. New controllers can be onboarded by governance without migrating funds.
MainnetController and ForeignController
Stateful entry points for the RELAYER role. Each controller:
- Checks the relayer's role, decrements (or increments) the relevant rate limit, validates parameters, and asks the proxy to execute the underlying call.
- Holds per-integration configuration:
maxSlippagesper pool/exchange, ERC-4626maxExchangeRatethresholds, Uniswap V4 tick limits, CCTPmintRecipientsper destination domain, LayerZerolayerZeroRecipientsper destination endpoint, OTC buffer registrations, and EthenadelegatedSignerstate.
RateLimits
src/RateLimits.sol. Stores all rate-limit data, keyed by bytes32. Each key resolves to a struct:
| Field | Description |
|---|---|
maxAmount | Absolute ceiling. The current limit can never exceed this. |
slope | Tokens per second of regeneration. |
lastAmount | Amount available at lastUpdated. |
lastUpdated | Block timestamp of the last update. |
The current limit is:
currentRateLimit = min(slope * (block.timestamp - lastUpdated) + lastAmount, maxAmount)A maxAmount of type(uint256).max flags an unlimited key (used for actions where the rate limit acts only as a whitelist gate, e.g., approval-only operations). A maxAmount of 0 means the key is not configured — any call that would consume it reverts. Only the CONTROLLER role on RateLimits (held by the active controller contract) may call triggerRateLimitDecrease or triggerRateLimitIncrease; only DEFAULT_ADMIN_ROLE may set new values directly.
ALMProxyFreezable
src/ALMProxyFreezable.sol. A 23-line subclass of ALMProxy used in narrower contexts. Two architectural differences from the canonical proxy:
- In this deployment pattern, the
CONTROLLERrole is intended to be granted directly to relayers rather than to aMainnetController/ForeignControllercontract (design intent documented indocs/ARCHITECTURE.md— the contract itself does not restrict who receives the role; the standard deploy path grants it to the controller contract). The relayer's calldata is the call. - A
FREEZERrole is introduced.removeController(address)revokes theCONTROLLERrole from a compromised relayer without a governance proposal.
This variant trades the parameter-validation layer of a full controller for operational simplicity, and is appropriate where the funds at risk or the action surface are constrained.
OTCBuffer
src/OTCBuffer.sol. UUPS-upgradeable buffer contract used to bound exposure to a single OTC counterparty. One OTCBuffer is deployed per whitelisted exchange. Its only state-changing method is the admin-callable approve(asset, allowance), which governance uses after deployment to grant the ALMProxy an infinite allowance over each whitelisted asset.
The OTC swap lifecycle (driven from MainnetController):
otcSend(exchange, asset, amount)— movesamountofassetfrom the proxy to the exchange's offchain address, after checkingLIMIT_OTC_SWAP(keyed by exchange) and confirming any prior swap isisOtcSwapReady.- The counterparty deposits the returned asset into the corresponding
OTCBuffer. otcClaim(exchange, asset)— pulls the entireOTCBufferbalance ofassetback into the proxy.isOtcSwapReady(exchange)returns true onceclaimed + rechargeRate · elapsed ≥ sent · maxSlippage, allowing the next send.
The infinite allowance is intentional: otcClaim always transfers the full balance, and a finite allowance would let an attacker donate a small amount to push the balance past the allowance and brick claims.
WEETHModule
src/WEETHModule.sol. UUPS-upgradeable helper that exists because EtherFi's withdrawal flow mints an NFT rather than returning ETH synchronously, and the proxy cannot safely hold or process NFTs.
requestWithdrawFromWeETHon the controller routes the unwrap →LiquidityPool.requestWithdrawcall so that the resultingWithdrawRequestNFTis minted to theWEETHModule.claimWithdrawal(requestId)on the module — only callable by its configuredalmProxy— verifies the request is valid and finalized, claims it, wraps received ETH into WETH, and transfers WETH back to the proxy.- The module never holds ETH or WETH outside the single claim transaction, and never holds NFTs except between request and claim.
Permissions
All contracts inherit OpenZeppelin AccessControl. The roles are:
| Role | Where it lives | What it can do |
|---|---|---|
DEFAULT_ADMIN_ROLE | All contracts | Grants and revokes roles. Sets rate-limit data on RateLimits. Sets per-integration parameters (maxSlippages, maxExchangeRates, mintRecipients, layerZeroRecipients, OTC config, Uniswap V4 tick limits). Authorizes UUPS upgrades on OTCBuffer and WEETHModule. Held by Sky governance. |
RELAYER | Controllers | Calls every action function on MainnetController and ForeignController. Assumed to be compromisable; logic must keep value inside the system and bound losses with rate limits and slippage. |
FREEZER | Controllers + ALMProxyFreezable | On controllers, calls removeRelayer(address) to revoke the RELAYER role from a single address. On ALMProxyFreezable, calls removeController(address) to detach a compromised relayer from the proxy. There is no global "freeze every action" function. |
CONTROLLER | ALMProxy, ALMProxyFreezable, RateLimits | On the proxy, the only role that can invoke doCall / doCallWithValue / doDelegateCall. On RateLimits, the only role that can call triggerRateLimitDecrease / triggerRateLimitIncrease. Held by the active controller contract (or, in the freezable variant, by relayers directly). |
Controller action surface
All functions below run with nonReentrant and require the RELAYER role. Each action consumes (or restores) the rate-limit key shown. Keys with an "→ address" suffix are combined with an asset, pool, vault, exchange, or destination via RateLimitHelpers before lookup; the underlying base key must also exist for the call to succeed.
MainnetController (src/MainnetController.sol)
| Family | Functions | Primary rate-limit key |
|---|---|---|
| USDS allocation | mintUSDS, burnUSDS | LIMIT_USDS_MINT (decreased on mint, restored on burn) |
| DAI ↔ USDS | swapUSDSToDAI, swapDAIToUSDS | none — 1:1 conversion via the Sky DaiUsds migrator, not rate-limited |
| Mainnet PSM | swapUSDSToUSDC, swapUSDCToUSDS | LIMIT_USDS_TO_USDC (decreased on out, restored on in) |
| Generic transfer | transferAsset | LIMIT_ASSET_TRANSFER → (asset, destination) |
| ERC-4626 | depositERC4626, withdrawERC4626, redeemERC4626 | LIMIT_4626_DEPOSIT / LIMIT_4626_WITHDRAW, both → token. Deposits also gated by per-token maxExchangeRate. |
| AAVE / SparkLend | depositAave, withdrawAave | LIMIT_AAVE_DEPOSIT / LIMIT_AAVE_WITHDRAW, both → aToken. Deposit also gated by per-aToken maxSlippage. |
| Curve stableswap | swapCurve, addLiquidityCurve, removeLiquidityCurve | LIMIT_CURVE_SWAP / LIMIT_CURVE_DEPOSIT / LIMIT_CURVE_WITHDRAW, all → pool. Requires non-zero maxSlippage[pool]. |
| Uniswap V4 | swapUniswapV4, mintPositionUniswapV4, increaseLiquidityUniswapV4, decreaseLiquidityUniswapV4 | LIMIT_UNISWAP_V4_SWAP / LIMIT_UNISWAP_V4_DEPOSIT / LIMIT_UNISWAP_V4_WITHDRAW, all → poolId. Tick range and tick spacing must be within uniswapV4TickLimits[poolId]. Pools must be hookless. |
| Lido wstETH | depositToWstETH, requestWithdrawFromWstETH, claimWithdrawalFromWstETH | LIMIT_WSTETH_DEPOSIT, LIMIT_WSTETH_REQUEST_WITHDRAW |
| EtherFi weETH | depositToWeETH, requestWithdrawFromWeETH, claimWithdrawalFromWeETH | LIMIT_WEETH_DEPOSIT, LIMIT_WEETH_REQUEST_WITHDRAW → weETHModule. Claim requires the request key to exist. |
| Ethena | setDelegatedSigner, removeDelegatedSigner, prepareUSDeMint, prepareUSDeBurn, cooldownAssetsSUSDe, cooldownSharesSUSDe, unstakeSUSDe | LIMIT_USDE_MINT, LIMIT_USDE_BURN, LIMIT_SUSDE_COOLDOWN |
| Maple | requestMapleRedemption, cancelMapleRedemption | LIMIT_MAPLE_REDEEM → mapleToken |
| Superstate | subscribeSuperstate | LIMIT_SUPERSTATE_SUBSCRIBE |
| SPK farms | depositToFarm, withdrawFromFarm | LIMIT_FARM_DEPOSIT / LIMIT_FARM_WITHDRAW, both → farm |
| Spark Vault | takeFromSparkVault | LIMIT_SPARK_VAULT_TAKE → sparkVault |
| OTC swap | otcSend, otcClaim | LIMIT_OTC_SWAP → exchange. Asset must be in otcWhitelistedAssets[exchange]. |
| CCTP bridging | transferUSDCToCCTP | LIMIT_USDC_TO_CCTP and LIMIT_USDC_TO_DOMAIN → destinationDomain |
| LayerZero OFT bridging | transferTokenLayerZero | LIMIT_LAYERZERO_TRANSFER → (oftAddress, destinationEndpointId). Source comment notes the integration ships with the rate limit set to zero pending integration testing. |
| ETH recovery | wrapAllProxyETH | (no rate limit; wraps any proxy ETH into WETH) |
ForeignController (src/ForeignController.sol)
| Family | Functions | Primary rate-limit key |
|---|---|---|
| PSM3 | depositPSM, withdrawPSM | LIMIT_PSM_DEPOSIT / LIMIT_PSM_WITHDRAW, both → asset. Both decrease (no cancellation; PSM3 is immutable and 1:1). |
| Generic transfer | transferAsset | LIMIT_ASSET_TRANSFER → (asset, destination) |
| ERC-4626 | depositERC4626, withdrawERC4626, redeemERC4626 | LIMIT_4626_DEPOSIT / LIMIT_4626_WITHDRAW, both → token. Deposit gated by per-token maxExchangeRate. |
| AAVE | depositAave, withdrawAave | LIMIT_AAVE_DEPOSIT / LIMIT_AAVE_WITHDRAW, both → aToken. Deposit gated by per-aToken maxSlippage. |
| Spark Vault | takeFromSparkVault | LIMIT_SPARK_VAULT_TAKE → sparkVault |
| CCTP bridging | transferUSDCToCCTP | LIMIT_USDC_TO_CCTP and LIMIT_USDC_TO_DOMAIN → destinationDomain. Splits across multiple CCTP burns when usdcAmount exceeds the per-message burn limit. |
| LayerZero OFT bridging | transferTokenLayerZero | LIMIT_LAYERZERO_TRANSFER → (oftAddress, destinationEndpointId). Ships with rate limit set to zero pending integration testing. |
Rate limit keys: full reference
The keccak256-derived constants currently declared on each controller. Keys flagged "→ ..." are combined with their suffix via RateLimitHelpers (makeAddressKey, makeAddressAddressKey, makeBytes32Key, makeUint32Key) before storage lookup.
Common to both controllers
LIMIT_4626_DEPOSIT → token
LIMIT_4626_WITHDRAW → token
LIMIT_AAVE_DEPOSIT → aToken
LIMIT_AAVE_WITHDRAW → aToken
LIMIT_ASSET_TRANSFER → (asset, destination)
LIMIT_LAYERZERO_TRANSFER → (oftAddress, destinationEndpointId)
LIMIT_SPARK_VAULT_TAKE → sparkVault
LIMIT_USDC_TO_CCTP
LIMIT_USDC_TO_DOMAIN → destinationDomainMainnetController-only
LIMIT_CURVE_DEPOSIT → pool
LIMIT_CURVE_SWAP → pool
LIMIT_CURVE_WITHDRAW → pool
LIMIT_FARM_DEPOSIT → farm
LIMIT_FARM_WITHDRAW → farm
LIMIT_MAPLE_REDEEM → mapleToken
LIMIT_OTC_SWAP → exchange
LIMIT_SUPERSTATE_SUBSCRIBE
LIMIT_SUSDE_COOLDOWN
LIMIT_UNISWAP_V4_DEPOSIT → poolId
LIMIT_UNISWAP_V4_SWAP → poolId
LIMIT_UNISWAP_V4_WITHDRAW → poolId
LIMIT_USDE_BURN
LIMIT_USDE_MINT
LIMIT_USDS_MINT
LIMIT_USDS_TO_USDC
LIMIT_WEETH_DEPOSIT
LIMIT_WEETH_REQUEST_WITHDRAW → weETHModule
LIMIT_WSTETH_DEPOSIT
LIMIT_WSTETH_REQUEST_WITHDRAWForeignController-only
LIMIT_PSM_DEPOSIT → asset
LIMIT_PSM_WITHDRAW → assetTrust assumptions
| Role / actor | Assumption |
|---|---|
DEFAULT_ADMIN_ROLE | Fully trusted. Held by Sky governance. |
RELAYER | Assumed compromisable. The controller must prevent value from leaving the system and must bound any loss to current rate-limit capacity and configured slippage. |
FREEZER | Trusted to revoke a compromised relayer (removeRelayer on controllers, removeController on the freezable proxy). Cannot perform allocation actions. |
| Ethena | Trusted not to honor mint/burn requests with more than 50bps slippage from a delegated signer. Ethena's offchain order-validity checks are part of the trust assumption. A compromised relayer can technically set a delegated signer; Ethena's offchain validation is the second line of defense. |
| EtherFi | Trusted to eventually finalize withdrawal requests. EtherFi admins can invalidate (and revalidate) withdrawal requests; an unfinalized request is a liveness issue, not a loss-of-funds issue. |
| OTC desks | Trusted to complete a swap up to the per-exchange LIMIT_OTC_SWAP ceiling. Maximum loss per exchange is bounded by a single outstanding swap. |
A compromised relayer can DOS Ethena unstaking; recovery is documented in test/mainnet-fork/Attacks.t.sol.
Operational requirements
These are conditions that must hold for each integration. They are not enforced by governance proposal review alone — most are also enforced in the contract.
- Token requirements. ERC-20 tokens must be non-rebasing, have at least 6 decimals, and behave as standard ERC-20s.
- Rate-limit keys are the whitelist. A venue with no governance-set
maxAmountfor its rate-limit key reverts on first interaction. Withdrawals require the matching deposit key to also exist (ERC-4626 and AAVE families). - ERC-4626 seeding. Vaults must have initial shares burned to an unrecoverable address before onboarding, to prevent first-deposit rounding attacks. The
maxExchangeRatemechanism is the second line of defense against donation attacks. - Curve and Uniswap V4 seeding. Pools must be seeded with initial liquidity to an unrecoverable address (e.g.,
address(1)) before onboarding. - Uniswap V4 restrictions. Only 1:1 stablecoin pools may be onboarded. Pools must be hookless (the rate-limit decrease is computed from pre/post balances and empty
hookDatais passed; a hook could otherwise manipulate balances mid-call). Tick limits andmaxSlippagemust be configured before any action succeeds. - OTC buffer deployment. After deploying a new
OTCBuffer, the admin must callapproveto grant an infinite allowance (type(uint256).max) to theALMProxyfor each whitelisted asset. Without this,otcClaimcan be bricked by a donation attack. - Slippage required. Curve, Uniswap V4, AAVE, and OTC operations require a non-zero
maxSlippagefor the target pool / aToken / exchange.
Verifying the live configuration
Live values change as governance onboards venues, raises ceilings, and rotates roles. The repository ships a Wake printer that enumerates which controller function consumes which rate-limit key and verifies the live configuration matches:
wake --config printers/wake.toml print rate-limitsA zero exit code indicates the spec is satisfied.
For live contract addresses (proxy, controllers, rate-limits, OTC buffers, weETH module, per-network deployments) refer to the Spark Address Registry. For role assignees and current per-key maxAmount / slope values, the RateLimits.getRateLimitData(key) and getCurrentRateLimit(key) view functions are authoritative.
Audits
Every released controller version has been audited. Cantina audited all versions from v1.0.0 through v1.10.0 (v1.2.0 never shipped as a final release); ChainSecurity audited v1.0.0–v1.7.0 and v1.10.0; Certora audited v1.8.0 and v1.9.0. Reports are linked from the security and audits page and live in audits/.