[ARFC] Onboard syrupUSDC to Aave V3 Core Instance

Given that the AAcA (Aave Asset class Allowlist) is currently a work-in-progress and the interest of the community in getting our technical analysis on the asset, we present it to the community.


syrupUSDC technical analysis


Summary


This is a technical analysis of all the smart contracts of the syrupUSDC asset and its main dependencies.


Disclosure: This is not an exhaustive security review of the asset, as performed by the Maple Team, but rather an analysis from an Aave technical service provider on various aspects we consider critical to review before a new type of listing.
Consequently, like with any security review, this is not an absolute statement about whether the asset is flawless/not. Only our opinion in what concerns problems with is integration with Aave and/or additional technical risks.



Analysis

The syrupUSDC is a yield-bearing, strategy-based asset that primarily generates yield through the allocation of funds to the Maple Lending Protocol. Additionally, it earns variable yields by deploying funds across various DeFi protocols. Users can deposit USDC to receive syrupUSDC, which accrues a fixed yield from these strategies. Institutional loans can access liquidity after passing Maple’s KYC requirements.


For the context of this analysis, our focus has been on the following aspects, critical for the correct and secure integration with Aave:

  • Mechanism to update the exchange rate of the asset for the underlying, in this case, USDC.
  • A recommendation of pricing strategy to be used in the integration asset <> Aave.
  • Any miscellaneous aspect of the code we can consider important.
  • Analysis of the access control (ownerships, admin roles) and the nature of the entities involved in the system. Regarding the table permissions’ holders and their criticality/risk, it is done following these guidelines:

Criticality Description
CRITICAL Usually super-admin functionality: it can compromise the system by completely changing its fundamentals, leading to loss of funds if misused or exploited. E.g. proxy admin, default admin
HIGH It can control several parts of the system with some risk of losing funds. E.g., general owners or admin roles involved in the flow of funds
MEDIUM It can cause malfunction and/or minor financial losses if misused or exploited. E.g., fee setter, fee recipient addresses
LOW It can cause system malfunctions but on non-critical parts without meaningful/direct financial losses. E.g., updating descriptions or certain non-critical parameters.

Risk Description
:green_circle: The role is controlled via a mechanism we consider safe, such as on-chain governance, a timelock contract, or setups involving multi-sigs under certain circumstances.
:yellow_circle: The role is controlled in a way that could expose the system and users to some risk depending on the actions it can control.
:red_circle: The role is controlled via a clearly non-secure method, representing risks for the system and users.

General points

  • The system is divided into upgradable and non-upgradable contracts.
  • For proxies, the system uses the Proxy standard from the MapleProxyFactory.
  • The MapleProxyFactory is governed by the Maple Governor, a 4-of-7 multisig held by founders and partners of the protocol.

Contracts

The following is a non-exhaustive overview of the main smart contracts involved with syrupUSDC.


syrupUSDC

The so-called syrupUSDC is the system’s core contract, implementing the ERC4626 standard that allows users to exchange USDC for the yield-bearing syrupUSDC token. It is a non-upgradable contract that has all permission operations controlled by the PoolManager.

  • Access Control
    • All functions in syrupUSDC are validated against the PoolManager.canCall() method, which evaluates whether the operations can be performed.
  • Deposits and Redemptions
    • Users can deposit USDC and receive syrupUSDC by calling the deposit(assets) function. Internally it calculates the amount of shares via previewDeposit(assets) getter. Alternatively, users can call the mint(shares) or use the depositWithPermit and mintWithPermit.
    • Users can initiate a redemption via the requestRedeem(shares) function. The shares will then be moved to the WithdrawalManagerQueue contract by the PoolManager via the WithdrawalManagerQueue.addShares(shares, owner) that creates the request into a FIFO queue.
    • After creating the request and the conditions are met (e.g, enough time has passed for users who locked their shares), the user can call the redeem(shares), which will invoke the Pool Manager and WithdrawalManagerQueue to calculate the amount of assets via the internal _calculateRedemption() method in the WithdrawalManagerQueue.
    • Users can cancel the request via the removeShares(amount) function and reclaim their syrupUSDC shares.
  • Exchange Rate
    • The system utilizes two distinct exchange rates: one for processing new deposits and the other for finalizing redemptions.
      • The deposit exchange rate relies on the previewDeposit(assets), which is calculated using the standard pattern of ERC4626 shares * totalSupply() / totalAssets(). The totalAssets() is fetched from PoolManager.totalAssets(), which returns the number of USDC held by the pool via USDC.balanceOf(syrupUSDC) plus the sum of USDC managed by all syrupUSDC strategies.
      • The redemption exchange rate is calculated in the internal _calculateRedemption(sharesToRedeem) getter of the WithdrawalManagerQueue contract using the formula totalAssetsWithLosses * sharesToRedeem / totalSupply(). The totalAssetsWithLosses variable is the totalAssets() minus the sum of unrealized losses by all syrupUSDC strategies.
      • The _calculateRedemption() also also verifies the availableLiquidity in the pool via USDC.balanceOf(syrupUSDC) , and if not enough liquidity, the final redemption amount will be adjusted and calculated as availableLiquidity * sharesToRedeem / totalSupply().
      • It is important to mention that the system utilizes asset.balanceOf(pool) as part of its accounting, which may result in donations of USDC to the system, potentially manipulating the exchange rate.

PoolManager

The Pool Manager is a master contract and core admin of the system. It holds the assets’ accounting of the pool and the admin functions to configure the system. It is an upgradable Proxy contract.

Permission Owner functions Criticality Risk
PoolDelegate - MPC (call time-locked) upgrade CRITICAL :green_circle:
SecurityAdmin - Safe 3-of-6 upgrade CRITICAL :yellow_circle:
factory (time-locked) setImplementation, migrate CRITICAL :green_circle:
ProtocolAdmins: Governor, PoolDelegate, OperationalAdmin (Safe 3-of-5) setPendingPoolDelegate, setIsStrategy, finishCollateralLiquidation, triggerDefault, addStrategy, setDelegateManagementFeeRate, setLiquidityCap, setPoolPermissionManager CRITICAL :yellow_circle:
PoolDelegate - MPC: Syrup Deployer withdrawCover HIGH :green_circle:
syrupUSDC requestRedeem, processRedeem, removeShares, HIGH :green_circle:
Strategies requestFunds HIGH :green_circle:

  • Access Control
    • ProtocolAdmins
      • A New pool delegate (which is a strategy manager and funds allocator of the system) can be set in a two-step process via the setPendingPoolDelegate(address) function. To finalize, the new delegate must confirm the transfer by calling the acceptPoolDelegate() function.
      • The pool permission manager contract can be set via the setPoolPermissionManager(address). Internally, it verifies if the contract is an of Maple’s contracts.
      • A strategy can be created via the addStrategy(address strategyFactory, bytes extraDeploymentData) function. It verifies if the strategyFactory is an instance of Maple contracts and calls strategyFactory.createInstance(extraDeploymentData), which deploys the new strategy contract. It finishes by enabling the isStrategy mapping and adding to the strategyList.
      • Strategies can be toggled as valid via the setIsStrategy(strategy, isStrategy) function It requires that the strategy address was previously included in the StrategyList.
      • Liquidations can be executed against the borrower’s collateral in the event of non-compliance with payment obligations within the specified period via the triggerDefault(address loan, address liquidatorFactory)method.
        Internally, it gets the lender contract via the loan address and calls the lender.triggerDefault(loan, liquidatorFactory) which returns whether the liquidation is finalized, the total losses, and the platform fees. If the liquidation is finalized, it triggers the internal handleCover(losses, fees) function, which calculates the amount that the PoolDelegateCover can pay as fees to the treasury and the losses to the pool. Otherwise, the Admin or delegate must call finishCollateralLiquidation(address loan).
        It is important to note that liquidations are only applied to strategies that allocate funds to the Maple Lending Protocol, not to DeFi strategies.
      • A liquidation that is not finalized can be terminated by calling the function finishCollateralLiquidation(address loan). Internally, it gets the lender contract via the loan address and calls lender.finishCollateralLiquidation(loan) which returns the total losses and the platform fees. It finishes by calling the internal handleCover(losses, fees).
      • The liquidity cap is configured via the setLiquidityCap(amount) method.
      • The delegate fee management can be set via the setDelegateManagementFeeRate(fee) method.
    • PoolDelegate
      • The funds from a liquidation (known as the cover amount ) can be withdrawn from the PoolDelegateCover and sent to an arbitrary address via the withdrawCover(amount, recipient) function. It invokes the PoolDelegateCover.moveFunds(amount, recipient) and ensures the PoolDelegateCover balance doesn’t fall below the minimum set for the PoolManager contract. Currently, the minimum is set to zero.
      • It is important to highlight that The Pool Delegate Cover is not actively in use across the protocol.
    • Pool
      • The requestRedeem(), processRedeem(), and removeShares() functions are initiated by users via the syrupUSDC Pool and forwarded to the WithdrawalManagerQueue.
  • Fund Strategies
    • Strategies can request funds from the pool via the requestFunds(address destination, amount) function. The call must be initiated by a valid strategy instance of Maple contracts and registered in the Pool Manager. This function also ensures, internally, that the pool balance doesn’t fall below the liquidity reserved for withdrawals.

PoolDelegateCover

The PoolDelegateCover is a contract that facilitates funds transfer and is the recipient of funds from liquidations. It’s a non-upgradable contract.

It is important to mention that the team has confirmed that the Pool Delegate Cover is not actively in use across the protocol. Maple previously supported external Pool Delegates to align economically with lenders.

  • Access Control
    • The PoolManager can transfer funds from this contract to any address via the moveFunds(amount, recipient) function.

WithdrawalManagerQueue

This contract allows users to submit withdrawal requests and holds custody of their shares until the request is processed. Once the withdrawal request is processed the shares in custody will be exchanged for assets using the current exchange rate and then transferred to the owner of the shares.

Permission Owner functions Criticality Risk
PoolDelegate - MPC (call time-locked) upgrade CRITICAL :green_circle:
SecurityAdmin - Safe 3-of-6 upgrade CRITICAL :yellow_circle:
factory setImplementation, migrate CRITICAL :yellow_circle:
Redeemers: PoolDelegate, Governor, OperationalAdmin (Safe 3-of-5) processRedemptions HIGH :green_circle:
ProtocolAdmins: PoolDelegate, Governor, OperationalAdmin (Safe 3-of-5) removeRequest, setManualWithdrawal HIGH :yellow_circle:
PoolManager addShares, processExit, removeShares HIGH :green_circle:

  • Access Control
    • Redeemers
      • The processRedemptions(shares) function finalizes the max number of redemption requests possible given the amount of shares. It calls the internal calculateRedemption(shares) function to obtain the redeemable shares amount and iterates through the maximum requests that the redeemable shares can support. Every request is processed via the internal _processRequest(requestId, amount) function, which checks whether the user opted to manually withdraw later via the redeem() function in the Pool contract or to automatically receive the underlying asset.
    • ProtocolAdmins or PoolDelegate
      • A redemption request from any user can be cancelled via the removeRequest(user) function. The shares are sent back to the user, and the request is deleted.
      • The manual withdrawal can be toggled from any user request via the setManualWithdrawal(user, isManual) function.
    • PoolManager
      • During the redemption request by a user in the Pool, the PoolManager takes the user shares and transfers them to the WithdrawalManager contract via the addShares(shares, owner) method, which creates the request in the queue.
      • When the user finalizes the redemption in the Pool, the PoolManager calls the processExit(shares, owner) to calculate the correct amount of shares that the user will receive.
      • If a user cancels their redemption request in the Pool, the PoolManager forwards it by calling the removeShares(amount, owner) function, which decreases the shares or deletes the request completely, depending on the amount. Then, it finalizes by transferring the syrupUSDC shares back to the owner.

Strategies

SyrupUSDC employs various strategies to generate yield for its lenders. There are two main strategies:

  • Loan Managers, who act as the principal yield source for the protocol, operate with fixed and open terms.
  • DeFi Strategies (for example, Aave and Sky) serve as a secondary yield source for the protocol, used to park funds that will be later utilized by Loan Managers or to fulfill withdrawal requests.

LoanManagers

The Loan Managers (Open and Fixed) are responsible for interacting with and managing their specific loan types on behalf of the Pool Manager, meaning they handle the flow of funds and, most importantly, provide the total assets under management to the Pool Manager’s account. They’re upgradable Proxy contracts.

Permission Owner functions Criticality Risk
PoolDelegate - MPC (call time-locked) upgrade CRITICAL :green_circle:
SecurityAdmin - Safe 3-of-6 upgrade CRITICAL :yellow_circle:
factory setImplementation, migrate CRITICAL :yellow_circle:
PoolDelegate - MPC: Syrup Deployer fund, proposeNewTerms, rejectNewTerms, acceptNewTerms HIGH :yellow_circle:*
PoolDelegate or Governor setAllowedSlippage, impairLoan HIGH :yellow_circle:
Governor removeLoanImpairment HIGH :yellow_circle:
PoolManager triggerDefault, finishCollateralLiquidation HIGH :green_circle:

  • It is outside of the scope of this analysis to evaluate the lending protocol/overcollateralization dynamics (e.g., lending terms), so there can be additional risk.
  • Access Control
    • Pool Delegate
      • Assets are borrowed from the pool via the fund(address loan). This function gets the funds via poolManager.requestFunds(loanManager, amount) and for Open-Term loans are pulled by the loan contract via the loan.fund() method, while for the Fixed-term, the assets are transferred directly to the loan contract.
      • New terms between the Borrower and Lender can be changed via the proposeNewTerms() function. This function allows to change the principal amount borrowed, periods, interest rate, and collateral required. For Open-Term loans, the new terms are proposed by the Lender and accepted via the claim() method. For Fixed-term loans, they are proposed by the borrower and accepted by the Lender (PoolDelegate) via the acceptNewTerms() method, and later the borrower can call the claim() method.
      • For the Fixed-Term loans, the new terms can be rejected by the Lender via the rejectNewTerms() function.
    • PoolDelegate or Governor
      • For Fixed-Term loans, the maximum slippage to liquidate the collateral asset can be set via the setAllowedSlippage(slippage) function.
      • When the borrower is unable to repay the loan, the PoolDelegate or the Governor can call the impairLoan() function, which allows the totalAssets() to account for the assetsUnderManagement() as unrealizedLosses() . This has a direct effect on the exchange rate syrupUSDC.
      • Only the Governor can remove a loan from an impaired state by calling the removeLoanImpairment() function.
    • PoolManager
      • When borrowers fail to repay their loans on the defined date, following a grace period, the PoolManager can liquidate the collateral via the triggerDefault() method. The totalAssets() will be adjusted accordingly to reflect the recovered assets from the liquidation.

DeFi Strategies

Inheriting the base functionality from the MapleAbstractStrategyContract, each strategy contains the logic to interact with a specific DeFi protocol. The strategy has 3 different states: Active, Inactive and Impaired (when it’s not possible to withdraw funds from the DeFi protocol). It’s an upgradable Proxy contract.

Permission Owner functions Criticality Risk
PoolDelegate - MPC (call time-locked) upgrade CRITICAL :green_circle:
SecurityAdmin - Safe 3-of-6 upgrade CRITICAL :yellow_circle:
factory setImplementation, migrate CRITICAL :yellow_circle:
StrategyManager: PoolDelegate - MPC: Syrup Deployer fundStrategy, withdrawFromStrategy HIGH :yellow_circle:*
ProtocolAdmins: PoolDelegate - MPC: Syrup Deployer, Governor, OperationalAdmin (Safe 3-of-5) deactivateStrategy, impairStrategy, reactivateStrategy, setStrategyFeeRate HIGH :red_circle:

  • It is outside of the scope of this analysis to evaluate the lending protocol/overcollateralization dynamics (e.g., lending terms), so there can be additional risk.

  • Access Control
    • StrategyManager
      • Assets are deposited into the specific DeFi Strategy via the fundStrategy(amount) method. This function gets funds via the poolManager.requestFunds(strategy, amount) and then interacts with the DeFi protocol by depositing funds (e.g, supply on Aave).
      • Later, assets can be withdrawn via the withdrawFromStrategy(amount) function. A fee is charged on the yield accrued during the period the strategy was active and sent to the treasury. The amount is then sent directly to the pool.
    • ProtocolAdmins
      • A fee Rate can be set via the setStrategyFeeRate(fee) method.
      • The strategy’s state via the deactivateStrategy(), reactivateStrategy(), and impairStrategy() methods. It is important to mention that the strategy’s state directly affects the totalAssets() used for redemptions, where:
        • Active state increases totalAssets() with the strategy’s assetsUnderManagement().
        • Impaired state decreases totalAssets() with the strategy’s assetsUnderManagement() as unrealizedLosses()
        • Inactive state returns zero;

MapleProxyFactory

The MapleProxyFactory is a generic Beacon Proxy Factory contract that handles the deployment of the system’s upgradable contracts, such as PoolManager, Strategies, and WithdrawalManagerQueue. Since the factory is contract-specific, it holds a default implementation that is used by the new proxy when no implementation is provided. Upgrades are triggered in the instance by the SecurityAdmin or PoolDelegate. It is a non-upgradable contract controlled by the Governor’s multisig.


Permission Owner functions Criticality Risk
Governor createInstance, enableUpgradePath, disableUpgradePath, registerImplementation, setDefaultVersion CRITICAL :yellow_circle:

  • Access Control
    • New Proxy contracts can be deployed via the createInstance(bytes arguments, bytes32 salt). Internally, it deploys the Proxy using create2, validates the implementation version, and finalizes by initializing the contract with the input arguments.
    • Upgrades are made in a 3-step process:
      • First, the implementation contract and its version and its initializer contract must be registered in the factory storage via the registerImplementation(uint256 version, address implementation, address initializer).
      • The Governor must authorize the upgrade from the current version to the new version and whether needs a migrator contract via the enableUpgradePath(uint256 fromVersion, uint256 toVersion, address migrator). It can also be cancelled via the disableUpgradePath(uint256 fromVersion, uint256 toVersion).
      • Finally, it is upgraded via the upgradeInstance(uint256 toVersion, bytes calldata arguments), which is called from the instance contract by the Pool Delegate (scheduled calls) or by the SecurityAdmin (instant upgrade). Internally, it verifies whether the upgrade was authorized and queries the toVersion implementation. Then, it calls proxy.setImplementation(implementation), and it migrates whether the migrator and arguments were provided via the proxy.migrate(migrator, arguments).
      • For upgrades invoked by the PoolDelegate, an additional timelock validation is checked in the proxy.setImplementation(implementation) function, which checks if the upgrade was previously scheduled using the globals.isValidScheduledCall() function. In contrast, when the upgrade is initiated by the SecurityAdmin, the timelock validation is bypassed.
      • The default implementation version for the factory’s proxies can be set via the setDefaultVersion(version).

Maple Globals

Globals is a central Maple contract for storing Maple system parameters. It includes a pauser and a timelock for upgrades for certain core contracts of the system. It’s an upgradable NonTransparentProxy contract owned by the Maple Governor.

Permission Owner functions Criticality Risk
Upgradable Admin: Governor setImplementation CRITICAL :red_circle:
Governor setPendingGovernor, setDefaultTimelockParameters, setTimelockWindows, unscheduleCall, setMapleTreasury, setMigrationAdmin, setOperationalAdmin, setSecurityAdmin, setPriceOracle, setValidCollateralAsset, setValidPoolAsset, setValidPoolDeployer, setManualOverridePrice CRITICAL :red_circle:
Governor or OperationalAdmin - Safe 3-of-5 activatePoolManager, setBootstrapMint, setCanDeployFrom, setValidBorrower, setValidInstanceOf, setValidPoolDelegate, setMaxCoverLiquidationPercent, setMinCoverAmount, setPlatformManagementFeeRate, setPlatformOriginationFeeRate, setPlatformServiceFeeRate HIGH :red_circle:
Governor or SecurityAdmin - Safe 3-of-6 setContractPause, setFunctionUnpause, setProtocolPause HIGH :green_circle:

  • Access Control
    • Governor
      • The default timelock delay period and its maximum duration to be executed after the delay period can be set by calling the setDefaultTimelockParameters(delay, duration) method. Currently, the default delay for scheduled calls is 7 days with a maximum execution-window duration of 2 days.
      • The delay period to scheduling calls for specific contract actions (e.g, upgradeability) can be set through the setTimelockWindow(contract, bytes32 functionId, delay, duration). It is important to mention that this function can bypass the default 7-day delay by entering a shorter period.
      • The scheduleCall(contract, functionId, calldata) function is permissionless, but e.g, upgrades must be called by the PoolDelegate or SecurityAdmin.
      • The Governor can remove scheduled calls through the unscheduleCall(caller, contract, functionId, callData) function.
      • The addresses of the access control admins, treasury, collateral assets and oracles can be set by the Governor.
    • Governor or OperationalAdmin
      • Different service fees can be set for a specific poolManager through the setPlatformManagementFeeRate(poolManager, fee), setPlatformOriginationFeeRate(poolManager, fee), setPlatformServiceFeeRate(poolManager, fee) methods.
      • New contracts into the system are registered, validated and activated by the Governor or OperationalAdmin.
    • Governor or SecurityAdmin
      • Specific contracts can be paused/unpaused via the setContractPause(address, bool).
      • The system can be paused/unpaused via the setProtocolPause(bool) function.
      • Specific functions can be unpaused by calling setFunctionUnpause(address) function.

Pricing strategy

SyrupUSDC can be characterized as a Lending Protocol, which means a new category of asset being onboarded to Aave. We agree that even if the more straightforward solution to price syrupUSDC with Capo adapter + CL USDC/USD Price feed, as suggested by the risk service provides, we still see this asset as a complex set of pricing, given that the collateral is not only USDC but also composed of n different abstract strategies, some of which can be straightforward to price, while at the same time, others require a more complex evaluation to reach a conclusion.

It’s also important to highlight that the exchange rate can be manipulated in both directions by increasing it through direct USDC donations to the pool or strategies, and decreasing it by impairing or disabling strategies.
Given this fragility of the rate, we highly recommend that if the DAO chooses to list syrupUSDC, it must not be borrowable.

Miscellaneous

  • The system has undergone several security reviews by Trail of Bits, Spearbit (Cantina), Three Sigma, and 0xMacro. They can be found in the Maple documentation here.
  • SyrupUSDC requires several trust assumptions, given that the entities that control the administrative functions, such as the Governor, Pool Delegate, Operational and Security Admins, are controlled by the Maple Team. The Team has provided a list of Assumptions, which highlights that all roles mentioned above are KYC-processed actors and are incentivized to act in the best interest of the protocol.
  • The Maple Team has confirmed that the address managing the Pool Delegate role is an MPC wallet.
  • Even taking those into account, it is important to highlight that the system has multiple moving parts with its custom strategies that will directly affect Aave.

Conclusion

We believe syrupUSDC does not face any infrastructural barriers to listing; however, we identify technical blockers that directly affect the security users’ funds on the Aave Protocol as follows:

  • The Globals singleton contract upgrades that are not time-locked have enough rights to modify critical parts of the system in a scenario where the upgradable admin (currently controlled by the Governor) has its signers’ keys exploited by a malicious actor.
  • The Governor + SecurityAdmin pattern can bypass the timelock, which is only enforced when the Pool Delegate initiates the upgradable calls of critical contracts within the system. This could break the system, similar to the scenario above, if a malicious actor gains complete control.
  • The impairment/disabling strategies action is not time-locked, which could lead to a rapid rate drop, resulting in multiple liquidations and potential bad debt on Aave. A similar scenario could occur if a malicious actor gains control over one of the addresses responsible for this role. We have recommended the Maple team to re-evaluate this flow, to be more defensive, even if keeping the impairment/disabling levers required for the protocol to work.

We discussed the previous points with the Maple Team, and they agreed to evaluate improvements, which, if applied, would remove the blockers from our side (after we re-check the system).

Additionally, it is outside of our scope to evaluate in depth each of the underlying strategies of the asset (both lending systems and DeFi allocation), each one of them involving additional risk, and dependency on the for example, the underlying venues where the funds are allocated.

3 Likes