Lumin
Website: https://lumin.finance/
Documentation: https://docs.lumin.finance/
Abstract
Lumin is a fixed-rate peer-to-peer lending protocol. Planned to support cross-chain loans, the current state support loans on EVM-compatible chains without cross-chain functionality. The first mainnet chain to deploy Lumin on is planned to be Arbitrum.
Before mainnet, the contracts will be tested on Binance Smartchain Testnet and Arbitrum Goerli. Since BSC is no Layer-2 chain, the Chainlink proxy will not have a sequencer set on BSC.
Users deposit their funds into the Lumin platform, which they can use to offer loans in the form of loan configurations. Borrowers create a loan for a specific loan configuration, moving funds from their deposit to the loan collateral.
Any user can liquidate loans when interest and/or principal payments are past due date, or when the value of the assets put down as collateral is lower than the collateral percentage agreed on upon taking the loan.
Contracts
The platform consists of several contracts, where the delivery at this moment is the deposit and loan aspect of the platform. These contracts can be found under ./src
. Lumin and Lumin OG NFTs can be found under ./lumin-token
, and are not part of the initial platform release. The Lumin token will be added once the launchpad partner and planning has been announced.
Foundry
The project uses Foundry, and all defaults (project layout, formatting, etc) are used unless specified otherwise in foundry.toml
.
Solidity style
Best practices as described on https://docs.soliditylang.org are used, with the following explicit choices / deviations:
All import statements are ES6-style named imports.
Although named imports do lead to large import statements and more cluttered lines at the top of Solidity files, it creates clarity about exactly which classes, structs, enums and global variables are imported and helps with understanding and refactoring the actual contract code, which is the most important part of a Solidity file.
The override
keyword is only used when overriding virtual functions.
Our opinion is that override
should be used to indicate that a function is currently being reimplemented or shadowed, as a hint to the developer. The fact that an interface function is being implemented does not help the developer, and it may even cause confusion when the same keyword has very distinct meanings.
The override
keyword is not mandatory when implementing interface functions since Solidity 0.8.8.
Logic clauses
Each logic clause is explicitly separated by its own brackets, not relying on operator precedence.
Bad example:
if (a + b * c < 10 || c > 20)
Good example:
if (((a + (b * c)) < 10) || (c > 20))
Documentation
NatSpec is used, with the addition of the following custom attributes:
@custom:role
when set, the method call can only be executed by the set role.Example:
@custom:role DEFAULT_ADMIN_ROLE
@custom:event
event name, one event per line, that the method can emit.Example:
custom:error AssetAdded
@custom:error
error name, one error per line, that the method can revert with. In case the error contains a numeric value used for finer-granularity error reporting, each numeric value is listed as if it were a separate error (on a new line), followed by a short error detail description.Examples:
@custom:error NotAuthorized
@custom:error IndexOutOfBounds
@custom:error ValidityCheck(100): value too low
@custom:error ValidityCheck(200): value too high
@custom:safe-call
<interface/contract>.method for methods performing calls to safe contracts.Note that a call stack containing a method marked with
@custom:unsafe-call
is always unsafe, despite the first method in the call stack being marked safe.Example:
@custom:safe-call IAssetManager.balanceOf
@custom:unsafe-call
<interface/contract>.method for methods performing calls to possibly contracts.Example:
@custom:unsafe-call IERC20.transfer
Access/AccessManager
The AccessManager
manages restricted contract calls. It is the authority contract for all Lumin's contracts.
Traits
The upgradeable contract implements the following traits:
OpenZeppelin
AccessManagerUpgradeable
The contract defines the following roles:
LUMIN_ADMIN_ROLE
for the Lumin team and DAO to upgrade and (un)pause contracts.ASSET_ADMIN_ROLE
the asset admins can add and enable/disable assets, price feeds and price feed proxies.LOAN_MANAGER_ROLE
the loan manager can perform a limited set of asset actions (deposit, withdraw, (un)lock)
UUPS Proxy
Until Lumin supports all planned features, including cross-chain lending support, the contracts remain upgradeable for fixes and new features. Disabling upgradeability is a choice to be made by the DAO.
Asset/AssetManager
The AssetManager
is responsible for holding the user's deposits, locking funds when funds are currently used as deposits, sending funds when loans are taken, etc. Apart from that, the asset's price feeds are managed in this contract as well.
Currently only ERC20 assets are supported. Every ERC20 asset shall have maximum 18 decimals. It is the responsibility of the asset admin to not add ERC20 assets with more decimals, or to update the Lumin contracts to support arbitrary decimals.
Traits
The upgradeable contract implements the following traits:
OpenZeppelin
AccessManagedUpgradeable
The contract has the following restricted methods:
LUMIN_ADMIN_ROLE
pause
unpause
upgradeToAndCall
ASSET_ADMIN_ROLE
addAsset
setAssetPriceFeed
setPriceFeedProxy
updateAssetEnabledStatus
updateAssetPriceFeedEnableStatus
updatePriceFeedProxyEnableStatus
LOAN_MANAGER_ROLE
assetLockUnlock
assetTransferOnLoanAction
Pausable
In case of suspicious behavior or the discovery of a bug or hack, the Lumin admins can pause the contract, blocking withdrawals from the platform until the issue has been resolved.
Reentrancy Guard
The Lumin protocol inherently trusts all contracts under direct management by the Lumin team. However, actions like withdrawing funds lead to cross-contract calls to contracts not under management by Lumin. Guarding against reentrancy attacks supports in keeping users' deposits safe.
UUPS Proxy
Until Lumin supports all planned features, including cross-chain lending support, the contracts remain upgradeable for fixes and new features. Disabling upgradeability is a choice to be made by the DAO. Upgrades can be performed by LUMIN_ADMIN_ROLE
after the contract has been paused.
Cross-contract interactions
The AssetManager
has the following cross-contract interactions:
LoanManager
The LoanManager
is granted access to AssetManager
by means of LOAN_MANAGER_ROLE
.
The LoanManager
(un)locks users funds and transfer assets, based on loan actions. For example, when a loan is taken, the borrower's collateral assets will be locked and the lender's lent assets will be sent to the borrower's wallet.
ERC20
Whenever the LoanManager
transfers funds, or the user withdraws unlocked assets from the contract, the AssetManager
will call safeTransfer
or safeTransferFrom
on the ERC20 asset.
Since this action can lead to a reentrancy attack, the Reentrancy Guard
is used when these external calls are made.
Asset ID
Assets are identified by the keccak256 hash of following tuple: (symbol, collection id). For ERC20 and ERC721 assets, this collection id is always 0. For the to-be-supported ERC1155, the collection id indicates the asset ID within the ERC1155 contract.
The asset contract address is not part of this tuple, as the contract addresses of assets across different chains can vary, even though these assets are the exact same asset from a cross-chain functionality point-of-view.
Loan/LoanManagerDelegator
Since the loan management contracts are very close to the maximum code size allowed by EIP-170, a delegator contract is used. All loan management state is stored in the context of this delegator contract.
Traits
The upgradeable contract implements the following traits:
Lumin
LoanManagerBase
OpenZeppelin
LoanManagerBase
This base contract defines the storage layout, used by the delegator and all delegatee contracts.
AccessManagedUpgradeable
The contract has the following restricted methods:
LUMIN_ADMIN_ROLE
pause
unpause
upgradeToAndCall
Pausable
In case of suspicious behavior or the discovery of a bug or hack, the Lumin team can pause the contract, blocking loan actions.
UUPS Proxy
Until Lumin supports all planned features, including cross-chain lending support, the contracts remain upgradeable for fixes and new features. Disabling upgradeability is a choice to be made by the DAO.
Reentrancy Guard
The Lumin protocol inherently trusts all contracts under direct management by the Lumin team. However, actions like taking loans lead to cross-contract calls to contracts not under management by Lumin. Guarding against reentrancy attacks supports in keeping users' deposits safe.
Cross-contract interactions
The delegator contract itself executes _delegatecall on LoanConfigManager
and LoanManager
. All other cross-contract calls are executed by the delegatee contracts. These calls are documented in the delegatee contracts.
Loan/LoanConfigManager
The LoanConfigManager
is responsible for the loan configurations created by lenders. This contract is used as delegatee contract, called by LoanManagerDelegator
. All storage used by LoanConfigManager
and LoanManager
stored in the context of LoanManagerDelegator
.
Traits
This delegatee contract implements the following traits:
Lumin
LoanManagerBase
Cross-contract interactions
The LoanConfigManager
has the following cross-contract interactions:
LoanManagerBase
This base contract defines the storage layout, used by the delegator and all delegatee contracts.
AssetManager
- Request user's asset deposits using
depositOf
. - Request asset's price feeds using
getAsset
.
The AssetManager
is a trusted Lumin contract that can only be set by the Lumin team. The AssetManager
does not call external contracts, so the use of this external contract does not impose a risk for reentrancy attacks.
Loan Configuration
Lenders offer loans in the form of loan configuration. Each user is allowed up to 10 loan configurations per asset ID per chain.
A loan configuration has the following attributes:
lender
: address of loan config creator and initial lender for the loanenabled
: true when the loan configuration is currently active, false otherwise. Loans cannot be taken on disabled configurationstotalLoanAmount
: total amount currently being lent and not paid back to thelender
of all loans using this loan configuration. Note that NFTs not owned bylender
lead to not deducingtotal
upon paying back the loan. The rationale for this is that the lent asset is not put back into thelender
's deposit upon payback.minLoanAmount
: minimum amount that can be borrowed per loanmaxLoanAmount
: maximum amount that can be borrowed per loanmaxTotalLoanAmount
: maximum value oftotal
; loans can only be taken for this loan configuration as long astotal
does not exceedmaxTotal
assetId
: asset ID to lendliquidationType
: not yet implementedtermPaymentType
: whether the borrower shall pay a linear share of the interest, or interest and principal, each termacceptedPriceFeeds
: bit field indicating which price feeds are allowed to be used to determine the value of the loaninterestPromille
: interest percentage * 10 (5.6% = 56 promille, 11% = 110 promille)terms
: loan duration expressed in terms (seeTERM_DURATION
)collateralPercentageMinus100
: the minimum collateral percentage which the borrower has to supply, in order to prevent liquidation. The value is stored after subtracting 100, as no loan should have less than 100% collateral value. The maximum collateral percentage is therefore type(uint8).max + 100 = 356.safetyBufferPercentage
: the minimum additional collateral percentage which the borrower has to supply upon taking a loan and changing the collateral. This percentage prevents liquidations based on small price changes after taking a loan.acceptSignedTerms
: not yet implemented
Accepted Asset Configuration
Each loan configuration has a configuration for accepted assets. This is a list of all assets accepted for collateralization and interest payment for the loan, as well as the allowed price feeds to use for value calculation. The lender defines this list, the borrower chooses exactly one price feed to use for the duration of this loan, for each asset.
An example configuration:
assetId[0] = 0x1111111111111111111111111111111111111111;
assetId[1] = 0x2222222222222222222222222222222222222222;
assetId[2] = 0x3333333333333333333333333333333333333333;
assetId[3] = 0x4444444444444444444444444444444444444444;
// Allow asset[0], asset[1] and asset[3] to be used for interest payments
useInterest[0] = true;
useInterest[1] = true;
useInterest[2] = false;
useInterest[3] = true;
// Allow asset[0] and asset[1] to be used as collateral
useCollateral[0] = true;
useCollateral[1] = true;
useCollateral[2] = false;
useCollateral[3] = false;
// Allow price feed index[3] for asset[0], asset[1] and asset[3]
// Allow price feed index[5] for asset[1], asset[2] and asset[3]
priceFeedIndices[0] = 8; // bitfield 0b00001000
priceFeedIndices[1] = 40; // bitfield 0b00101000
priceFeedIndices[2] = 32; // bitfield 0b00100000
priceFeedIndices[3] = 40; // bitfield 0b00101000
Loan/LoanManager
The LoanManager
delegatee contract creates loans, allows liquidations, paying back loans and interest, and changing collateral.
The contract is called by LoanManagerDelegator
.
Traits
The upgradeable contract implements the following traits:
Lumin
LoanManagerBase
Cross-contract interactions
The LoanManager
has the following cross-contract interactions:
LoanManagerBase
This base contract defines the storage layout, used by the delegator and all delegatee contracts.
AssetManager
- (Un)lock user's deposits using
assetLockUnlock
. - Transfers user's deposits using
assetTransferOnLoanAction
. This can be an internal transfer (e.g. paying interest) or an external transfer (taking a loan, thereby withdrawing the borrowed assets to the user's wallet). - Get asset's price feed proxies using
getPriceFeedProxyAddress
. - Get asset's price feeds using
getAsset
.
PriceFeedProxy
- Get asset's dollar value using
getDollarPrice
.
Price Feed
Asset values are read from price feeds using price feed proxy. An example of a price feed proxy is a Chainlink proxy, or a DIA proxy. These proxies read the value of an asset from e.g. Chainlink's or DIA's smart contracts, and returns the Dollar value of one unit of asset (e.g. 1 ETH, 1 BTC, 1 USDT).
Each network can have up to 8 different price feed proxies. All proxies and the price feeds for each asset for that proxy, are matched by their index.
For example:
-
priceFeedProxy[3]
=Chainlink
-
priceFeedProxy[5]
=DIA
-
BTC.priceFeed[3]
=0x1234
(Chainlink) -
ETH.priceFeed[3]
=0x5678
(Chainlink) -
ETH.priceFeed[5]
=0x90AB
(DIA)
In this example, ETH supports Chainlink and DIA price feeds, and BTC only supports the Chainlink price feed.
Loan configurations contain a list of accepted price feeds using a bit mask. For example, 00000101 indicates that priceFeed[0]
and priceFeed[2]
are accepted.
When taking a loan, the borrower selects exactly one price feed for each asset accepted by the lender. The selected price feed cannot be changed during the loan; the values used in all calculations for paying interest, liquidating, etc. are based on the price feeds chosen by the borrower, where the borrower can choose from those price feeds allowed by the lender in the loan configuration.
Loan/LoaNFT
A LoaNFT
signifies the ownership of a loan. For each loan, a total of 10 LoaNFT
s are minted. When the loan has been closed, the LoaNFT
s are burned. Every interest and principal payment done to a loan is shared over the LoaNFT
owners, where each owner receives their respective share of the payment.
When a loan is liquidated, LoaNFT
owners receive their share of the collateral according to the same share split.
Traits
The upgradeable contract implements the following traits:
OpenZeppelin
AccessManagedUpgradeable
The contract has the following restricted methods:
LOAN_MANAGER_ROLE
burn
mint
ERC1155Upgradeable
LoaNFT
s are ERC1155 tokens, where the token ID corresponds to the loan ID and each token ID exist of 10 tokens (shares).
UUPS Proxy
Until Lumin supports all planned features, including cross-chain lending support, the contracts remain upgradeable for fixes and new features. Disabling upgradeability is a choice to be made by the DAO.
Cross-contract interactions
The LoaNFT
has the following cross-contract interactions:
LoanManager
- LoanManager mints 10
LoaNFT
s to the lender upon creating a loan. - LoanManager burns 10
LoaNFT
s upon closing a loan. - LoanManager reads current
LoanNFT
ownership and amount of shares for each owner.
Example contract flow
After deploying and configuring the contracts, the admin adds the following assets (note that this is pseudocode):
assetETH = AssetManager.addAsset(ethTokenAddress, "ETH", 0)
assetBTC = AssetManager.addAsset(btcTokenAddress, "BTC", 0)
assetUSDC = AssetManager.addAsset(usdcTokenAddress, "USDC", 0)
For all of these assets, there is both a Chainlink and DIA price feed proxy:
priceFeedProxy[0] = AssetManager.setPriceFeedProxy(0, chainlink);
priceFeedProxy[1] = AssetManager.setPriceFeedProxy(1, dia);
Alice deposits assets into her account:
AssetManager.assetDepositWithdraw(assetETH, AssetActionType.Deposit, 1)
AssetManager.assetDepositWithdraw(assetUSDC, AssetActionType.Deposit, 1000)
Bob deposits assets as well:
AssetManager.assetDepositWithdraw(assetETH, AssetActionType.Deposit, 10)
AssetManager.assetDepositWithdraw(assetBTC, AssetActionType.Deposit, 2)
Alice wants to lend out her USDC, with a minimum loan size of 100 USDC, maximum of 250 USDC, and does not want to lend out more than 500 USDC at any point in time with this configuration. She only accepts ETH as collateral, but she is fine receiving interest payments in USDC and ETH. She does not want BTC to be used as collateral or for interest payments. She is fine using either DIA or Chainlink for the loans taken from her configuration.
configId_1 = LoanManager.createConfig:
- asset : assetUSDC
- minLoanAmount : 100
- maxLoanAmount : 250
- maxTotalLoanAmount : 500
- terms : 4 (120 days)
- interestPromille : 120 (12%)
- assets:
- ETH : interest, collateral
- USDC : interest
This leads to the following contract state:
LoanConfigManager.loanConfig[1]
- lender : Alice
- asset : assetUSDC
- minLoanAmount : 100
- maxLoanAmount : 250
- maxTotalLoanAmount : 500
- terms : 4 (30 days)
- interestPromille : 120 (12%)
- assetUsage :
- assetId[0] : assetETH
- assetId[1] : assetUSDC
- priceFeedIndices[0] : 0b00000011
- priceFeedIndices[1] : 0b00000011
The following container stores the loan configuration IDs created for a specific asset, per user. This is to quickly search all active loans for each user, and to make sure that people do not create too many loans IDs and bloat the contract data.
LoanConfigManager.userConfigIds[Alice]
[assetETH]: [1]
Bob wants to borrow 150 USDC from Alice, he creates a loan using Alice's configuration (ID 1). As collateral he puts down 0.2 ETH. For ETH valuation he wants to use DIA, but for USDC he prefers Chainlink.
loan_1 = LoanManager.createLoan:
- configId : 1
- loanAmount : 150
- priceFeedIndex : 0 (Chainlink)
- loan assets:
- [0 (ETH)]
- amount : 0.2
- priceFeedIndex : 1 (DIA)
- [1 (USDC)]
- amount : 0
- priceFeedIndex : 0 (Chainlink)
This leads to the following contract state:
LoanManager.loans[1]
- borrower : Bob
- loanStart : <day 0>
- loan
- configId : 1
- loanAmount : 150
- pendingPrincipalAmount: 150
- pendingInterestAmount : 18 // 12% of 150
- priceFeedIndex : 0
- collateralAssets
- amount[0] : 0.2
- amount[1] : 0
- priceFeedIndex[0] : 1
- priceFeedIndex[1] : 0
LoaNFT._owners[1]
- address : Bob
Note that the borrower can choose a different price feed for an asset used for payments, than that used for the loan valuation.
Upon creation of the loan, Bob's 0.2 ETH remain in his deposit, but become locked. The borrowed 150 USDC is transferred to Bob's wallet, and leave the Lumin platform immediately.
If termPaymentType
is Interest
or InterestAndPrincipal
, Bob needs to pay his first instalment before the first term of 30 days are over. Instalments are linear payments over the interest (and optionally the principal) due, but they can be paid in advance. Bob decides to already pay half of the due interest. Remember that Alice allows interest payments with ETH and USDC.
12% of $150 = $18 total interest. Bob wants to pay half, so he puts down 0.005 ETH (assume this is 9 USDC).
LoanManager.pay:
- loanId : 1
- interest assets:
- [0 (ETH)] : 0.005
- [1 (BTC)] : 0
- principal : 0
This leads to the following updated contract state:
LoanManager.loans[1]
- borrower : Bob
- loanStart : <day 0>
- loan
- configId : 1
- loanAmount : 150
- pendingPrincipalAmount: 150
- pendingInterestAmount : 9
- priceFeedIndex : 0
Note that the price feeds cannot be set anymore; Bob decided on the price feeds to use when he took the loan.
Bob's loan has a duration of 4 terms (120 days), which means that his 50% payment of the interest will cover the loan until 60 days after loanStart
, but they need topping up in time. Paying interest can be done by any user on the platform, provided that they pay with the assets and price feeds selected by Bob when creating the loan.
Note that the percentage of principal paid is not allowed to be higher than the percentage of interest paid. The possible term payment options are as follows: None
, Interest
, InterestAndPrincipal
.
None
: interest and principal can be paid at any time, maximum by the end of the loan duration.Interest
: interest and principal can be paid at any time, where the interest payment needs to be paid at least linearly in 30-day installments. Payments can be done upfront.InterestAndPrincipal
: interest and principal can be paid at any time, but need to be paid at least linearly in 30-day installments. Payments can be done upfront.
Bob can choose to update his collateral by providing a list of all new collateral amounts. Any surplus will become unlocked deposits, any tokens to be provided as extra collateral will be locked.
For example, reducing collateral:
LoanManager.setCollateral
- loanId : 1
- collateral assets:
- [0 (ETH)] : 0.004
- [1 (BTC)] : 0
As long as the Dollar value of the collateral is at least the collateral + safety buffer percentage as required by Alice in the loan configuration, the collateral can be changed.
In case the collateral value is lower than the collateral percentage as configured by Alice, or the total interest paid is not enough according to the linear payment per term, the loan can be liquidated:
LoanManager.liquidate(1)
This splits all locked collateral as follows:
- 25% to the liquidator's free deposit
- 50% to the
LoanManager
contract's free deposit, to be split between platform fees and staker rewards - 25% to the loan's owners, where ownership is determined by the corresponding
LoaNFT
ownership shares. Each loan has a total of 10 correspondingLoaNFT
shares, each signifying a 10% ownership of the loan.
After the collateral is split, the loan and corresponding LoaNFT
s are deleted.
Note that the lenders are the persons holding the corresponding LoaNFTs at the time of a loan action (interest, loan repayment, liquidation).
The order of loan payments is as follows:
- platform fee payment (automatically deducted upon creating a loan)
- interest payment and / or principal repayment
Only after a successful platform loan payment or liquidation, the loan is fully removed.
Platform fees are 10% of the total interest to be paid over a loan, and are automatically paid upon taking a loan. This means that the borrower receives the full borrowed value, minus 10% of the total interest due, upon taking a loan.
For example:
loanConfig[123]:
interestPromille : 30 // 3%
minLoanAmount : 1000
...
loan[1000]:
configId : 123
loanAmount : 1000
The total interest due is 3% over 1000 = 30
. The platform fees are 10% = 3
, so the borrower receives 1000 - 3 = 997
upon taking the loan.