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 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 (MainnetController on Ethereum, ForeignController on each supported L2).
  • Enforce per-integration whitelists and exposure caps via a stateful RateLimits contract keyed by keccak256 identifiers.
  • Provide an emergency revocation path via the FREEZER role 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's CONTROLLER role directly. This is intended for lower-criticality state where governance accepts a wider attack surface in exchange for operational simplicity, and where the FREEZER quick-revoke path is still preserved.
  • OTCBuffer and WEETHModule, 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 / …).

Spark ALM Controller call flow: relayer to controller to proxy to external venue, with rate-limit checks

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.

USDS mint sequence: relayer calls mintUSDS, rate limit decreases, proxy draws from the Sky allocator vault and pulls USDS from the buffer

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: maxSlippages per pool/exchange, ERC-4626 maxExchangeRate thresholds, Uniswap V4 tick limits, CCTP mintRecipients per destination domain, LayerZero layerZeroRecipients per destination endpoint, OTC buffer registrations, and Ethena delegatedSigner state.

RateLimits

src/RateLimits.sol. Stores all rate-limit data, keyed by bytes32. Each key resolves to a struct:

FieldDescription
maxAmountAbsolute ceiling. The current limit can never exceed this.
slopeTokens per second of regeneration.
lastAmountAmount available at lastUpdated.
lastUpdatedBlock 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 CONTROLLER role is intended to be granted directly to relayers rather than to a MainnetController / ForeignController contract (design intent documented in docs/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 FREEZER role is introduced. removeController(address) revokes the CONTROLLER role 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):

  1. otcSend(exchange, asset, amount) — moves amount of asset from the proxy to the exchange's offchain address, after checking LIMIT_OTC_SWAP (keyed by exchange) and confirming any prior swap is isOtcSwapReady.
  2. The counterparty deposits the returned asset into the corresponding OTCBuffer.
  3. otcClaim(exchange, asset) — pulls the entire OTCBuffer balance of asset back into the proxy.
  4. isOtcSwapReady(exchange) returns true once claimed + 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.

  • requestWithdrawFromWeETH on the controller routes the unwrap → LiquidityPool.requestWithdraw call so that the resulting WithdrawRequestNFT is minted to the WEETHModule.
  • claimWithdrawal(requestId) on the module — only callable by its configured almProxy — 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:

RoleWhere it livesWhat it can do
DEFAULT_ADMIN_ROLEAll contractsGrants 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.
RELAYERControllersCalls 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.
FREEZERControllers + ALMProxyFreezableOn 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.
CONTROLLERALMProxy, ALMProxyFreezable, RateLimitsOn 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).

Role topology: Sky Governance holds DEFAULT_ADMIN_ROLE, which grants RELAYER, FREEZER and CONTROLLER; relayer drives the controller, freezer revokes, controller acts on proxy and rate limits

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)

FamilyFunctionsPrimary rate-limit key
USDS allocationmintUSDS, burnUSDSLIMIT_USDS_MINT (decreased on mint, restored on burn)
DAI ↔ USDSswapUSDSToDAI, swapDAIToUSDSnone — 1:1 conversion via the Sky DaiUsds migrator, not rate-limited
Mainnet PSMswapUSDSToUSDC, swapUSDCToUSDSLIMIT_USDS_TO_USDC (decreased on out, restored on in)
Generic transfertransferAssetLIMIT_ASSET_TRANSFER → (asset, destination)
ERC-4626depositERC4626, withdrawERC4626, redeemERC4626LIMIT_4626_DEPOSIT / LIMIT_4626_WITHDRAW, both → token. Deposits also gated by per-token maxExchangeRate.
AAVE / SparkLenddepositAave, withdrawAaveLIMIT_AAVE_DEPOSIT / LIMIT_AAVE_WITHDRAW, both → aToken. Deposit also gated by per-aToken maxSlippage.
Curve stableswapswapCurve, addLiquidityCurve, removeLiquidityCurveLIMIT_CURVE_SWAP / LIMIT_CURVE_DEPOSIT / LIMIT_CURVE_WITHDRAW, all → pool. Requires non-zero maxSlippage[pool].
Uniswap V4swapUniswapV4, mintPositionUniswapV4, increaseLiquidityUniswapV4, decreaseLiquidityUniswapV4LIMIT_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 wstETHdepositToWstETH, requestWithdrawFromWstETH, claimWithdrawalFromWstETHLIMIT_WSTETH_DEPOSIT, LIMIT_WSTETH_REQUEST_WITHDRAW
EtherFi weETHdepositToWeETH, requestWithdrawFromWeETH, claimWithdrawalFromWeETHLIMIT_WEETH_DEPOSIT, LIMIT_WEETH_REQUEST_WITHDRAW → weETHModule. Claim requires the request key to exist.
EthenasetDelegatedSigner, removeDelegatedSigner, prepareUSDeMint, prepareUSDeBurn, cooldownAssetsSUSDe, cooldownSharesSUSDe, unstakeSUSDeLIMIT_USDE_MINT, LIMIT_USDE_BURN, LIMIT_SUSDE_COOLDOWN
MaplerequestMapleRedemption, cancelMapleRedemptionLIMIT_MAPLE_REDEEM → mapleToken
SuperstatesubscribeSuperstateLIMIT_SUPERSTATE_SUBSCRIBE
SPK farmsdepositToFarm, withdrawFromFarmLIMIT_FARM_DEPOSIT / LIMIT_FARM_WITHDRAW, both → farm
Spark VaulttakeFromSparkVaultLIMIT_SPARK_VAULT_TAKE → sparkVault
OTC swapotcSend, otcClaimLIMIT_OTC_SWAP → exchange. Asset must be in otcWhitelistedAssets[exchange].
CCTP bridgingtransferUSDCToCCTPLIMIT_USDC_TO_CCTP and LIMIT_USDC_TO_DOMAIN → destinationDomain
LayerZero OFT bridgingtransferTokenLayerZeroLIMIT_LAYERZERO_TRANSFER → (oftAddress, destinationEndpointId). Source comment notes the integration ships with the rate limit set to zero pending integration testing.
ETH recoverywrapAllProxyETH(no rate limit; wraps any proxy ETH into WETH)

ForeignController (src/ForeignController.sol)

FamilyFunctionsPrimary rate-limit key
PSM3depositPSM, withdrawPSMLIMIT_PSM_DEPOSIT / LIMIT_PSM_WITHDRAW, both → asset. Both decrease (no cancellation; PSM3 is immutable and 1:1).
Generic transfertransferAssetLIMIT_ASSET_TRANSFER → (asset, destination)
ERC-4626depositERC4626, withdrawERC4626, redeemERC4626LIMIT_4626_DEPOSIT / LIMIT_4626_WITHDRAW, both → token. Deposit gated by per-token maxExchangeRate.
AAVEdepositAave, withdrawAaveLIMIT_AAVE_DEPOSIT / LIMIT_AAVE_WITHDRAW, both → aToken. Deposit gated by per-aToken maxSlippage.
Spark VaulttakeFromSparkVaultLIMIT_SPARK_VAULT_TAKE → sparkVault
CCTP bridgingtransferUSDCToCCTPLIMIT_USDC_TO_CCTP and LIMIT_USDC_TO_DOMAIN → destinationDomain. Splits across multiple CCTP burns when usdcAmount exceeds the per-message burn limit.
LayerZero OFT bridgingtransferTokenLayerZeroLIMIT_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        → destinationDomain

MainnetController-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_WITHDRAW

ForeignController-only

LIMIT_PSM_DEPOSIT   → asset
LIMIT_PSM_WITHDRAW  → asset

Trust assumptions

Role / actorAssumption
DEFAULT_ADMIN_ROLEFully trusted. Held by Sky governance.
RELAYERAssumed compromisable. The controller must prevent value from leaving the system and must bound any loss to current rate-limit capacity and configured slippage.
FREEZERTrusted to revoke a compromised relayer (removeRelayer on controllers, removeController on the freezable proxy). Cannot perform allocation actions.
EthenaTrusted 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.
EtherFiTrusted 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 desksTrusted 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 maxAmount for 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 maxExchangeRate mechanism 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 hookData is passed; a hook could otherwise manipulate balances mid-call). Tick limits and maxSlippage must be configured before any action succeeds.
  • OTC buffer deployment. After deploying a new OTCBuffer, the admin must call approve to grant an infinite allowance (type(uint256).max) to the ALMProxy for each whitelisted asset. Without this, otcClaim can be bricked by a donation attack.
  • Slippage required. Curve, Uniswap V4, AAVE, and OTC operations require a non-zero maxSlippage for 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-limits

A 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/.