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 loan
  • enabled: true when the loan configuration is currently active, false otherwise. Loans cannot be taken on disabled configurations
  • totalLoanAmount: total amount currently being lent and not paid back to the lender of all loans using this loan configuration. Note that NFTs not owned by lender lead to not deducing total upon paying back the loan. The rationale for this is that the lent asset is not put back into the lender's deposit upon payback.
  • minLoanAmount: minimum amount that can be borrowed per loan
  • maxLoanAmount: maximum amount that can be borrowed per loan
  • maxTotalLoanAmount: maximum value of total; loans can only be taken for this loan configuration as long as total does not exceed maxTotal
  • assetId: asset ID to lend
  • liquidationType: not yet implemented
  • termPaymentType: whether the borrower shall pay a linear share of the interest, or interest and principal, each term
  • acceptedPriceFeeds: bit field indicating which price feeds are allowed to be used to determine the value of the loan
  • interestPromille: interest percentage * 10 (5.6% = 56 promille, 11% = 110 promille)
  • terms: loan duration expressed in terms (see TERM_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 LoaNFTs are minted. When the loan has been closed, the LoaNFTs 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

LoaNFTs 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 LoaNFTs to the lender upon creating a loan.
  • LoanManager burns 10 LoaNFTs 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 corresponding LoaNFT shares, each signifying a 10% ownership of the loan.

After the collateral is split, the loan and corresponding LoaNFTs 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.