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 SLL capital in an
ALMProxyon each network. Segregated PAUs (see Diamond PAUs) hold their own funds in a separateALMProxy. - 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 three narrower contexts:
ALMProxyFreezable, a variant of the proxy where relayers are granted anALLOCATOR_ROLEand call the proxy directly. This is intended for lower-criticality state where governance accepts a wider attack surface in exchange for operational simplicity, and where aFREEZER_ROLEquick-revoke path is still preserved.- Diamond PAUs, a newer modular controller stack that runs next to the controllers on some networks. See Diamond PAUs.
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 SLL capital on its network. 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
A variant of ALMProxy used in narrower contexts, for example as the allocator on Spark-curated Morpho vaults. The live deployments on Ethereum, Base, Arbitrum, Avalanche, Robinhood and X Layer use the diamond-pau ALMProxyFreezable v1.12.0, which replaced the earlier spark-alm-controller version in the June 4, 2026 spell. Two architectural differences from the canonical proxy:
- Relayers are granted the
ALLOCATOR_ROLEand calldoCall/doCallWithValueon the proxy directly, rather than going through aMainnetController/ForeignControllercontract. The relayer's calldata is the call. - A
FREEZER_ROLEis introduced.removeAllocator(address)revokes theALLOCATOR_ROLEfrom a compromised relayer without a governance proposal.
The earlier src/ALMProxyFreezable.sol in spark-alm-controller used the CONTROLLER role and removeController(address) for the same purpose.
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.
Diamond PAUs
Spark has also deployed a modular controller stack from the diamond-pau repository (v1.14.0). Each PAU consists of a Controller with pluggable facets (for example CCTP, Aave, ERC-4626, LayerZero, Spark Vault, transfer and Uniswap V4 facets), its own RateLimits and AccessControls contracts, and an AdministeredAgent that acts as the relayer. Per-network Beacon and PAUFactory contracts deploy new PAUs. The AdministeredAgent has actors (hot wallets that submit transactions), grantors that can add actors, and revokers (freezers) that can remove them.
Live deployments:
- Arbitrum (parallel PAU). Since the October 8, 2026 spell, a PAU
Controllerholds theCONTROLLERrole on the ArbitrumALMProxynext to the v1.8.0ForeignController. It bridges USDC to Ethereum over CCTP V2, limited to 5M USDC with 50M USDC per day of regeneration. The Soter freezer multisig is a revoker and the Soter grantor multisig is a grantor on itsAdministeredAgent. - Ethereum and X Layer (segregated spUSDC PAUs). Each has its own
ALMProxy,Controller,RateLimits,AccessControlsandAdministeredAgent, used for the spUSDC vault on that network. Funds in these proxies are separate from the main SLLALMProxy.
Addresses are listed in the Spark Address Registry.
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 | Calls removeRelayer(address) to revoke the RELAYER role from a single address. There is no global "freeze every action" function. |
FREEZER_ROLE | ALMProxyFreezable | Calls removeAllocator(address) to detach a compromised relayer from the proxy. |
CONTROLLER | ALMProxy, 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 (on Arbitrum, also by the parallel PAU Controller). |
ALLOCATOR_ROLE | ALMProxyFreezable | Calls doCall / doCallWithValue on the freezable proxy. Held 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)
The controller still supports every family below, but governance can deactivate a venue by setting its rate limits to zero. The September 10, 2026 spell did this for Ethena (USDe mint/burn, sUSDe cooldown and deposits), Maple syrupUSDC and syrupUSDT, Superstate USTB and USCC, and several Curve pools, among others.
| 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). Live for USDT OFT bridging to Arbitrum (July 2, 2026 spell: 5M USDT max, 50M USDT per day) and X Layer (July 16, 2026 spell: 5M USDT max, 100M USDT per day). |
| ETH recovery | wrapAllProxyETH | (no rate limit; wraps any proxy ETH into WETH) |
ForeignController (src/ForeignController.sol)
The table follows the v1.10.0 source, which is deployed on Robinhood and X Layer. Arbitrum, Base, Avalanche, Optimism and Unichain still run ForeignController v1.8.0, which also exposes setSupplyQueueMorpho, updateWithdrawQueueMorpho and reallocateMorpho for Morpho vault allocation. Ethereum runs MainnetController v1.10.0. Controller v1.11.0 has been tagged but is not yet deployed.
| 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). |
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, removeAllocator on the freezable proxy, revoker role on PAU AdministeredAgent contracts). 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/.