Preliminary Tinlake Audit Report:

Lender and MKR Adapter

dapp.org

fv@dapp.org

last updated: 21.06.2021



Table of Contents

Summary

From May 31st to June 6 2021, a team of four engineers spent a total of 8 person weeks reviewing the lender modules and the MakerDAO integration for Centrifuge's Tinlake system.

The phase 1 review was carried out against the following git commits:

The audit engagement is split into two phases. The next phase will cover the borrower modules over an additional 8 person week period.

Team

The review was carried out by the following members of the dapp.org collective:

  • David Terry
  • Denis Erfurt
  • Jenny Pollack
  • Martin Lundfall

Tests

To facilitate the analysis, the team implemented a suite of integration, property tests and invariant tests using the dapptools toolbox, which can be found at https://github.com/dapp-org/tinlake-tests/. This includes demonstrations of most of the vulnerabilities described in this document.

Changelog

A revision history for this document can be found here

System Overview

Tinlake is a system that allows lenders to provide credit to holders of non fungible assets. The assets are represented on chain as ERC-721 tokens. Borrowers lock these assets in tinlake, and can borrow currency up to some configurable percentage of the assessed value of their asset.

Different classes of assets are held in different pools, with "asset originators" having the power to approve individual assets for borrowing in a pool, and to assess their value. Each pool is a complete new deployment of the contracts.

Lenders must undergo KYC, and once approved by Tinlake governance, can invest in either the junior or senior tranche. If loans default and losses occur, they will be taken from the junior tranche before the senior tranche. The senior tranche has a fixed interest rate, whereas the junior tranche receives a variable (and potentially higher) rate of return. In return for providing currency to borrowers, lenders receive a fungible token (DROP for the senior tranche, and TIN for the junior tranche), this token can be exchanged for currency at a price determined by the pool contracts.

In order to provide some resistance against transaction reordering attacks and to prevent races, the lending side of the system makes use of an epoch mechanism. Lenders supply currency, or redeem their DROP / TIN for currency by first submitting an order to the pool, and then waiting for the epoch to close. Once the epoch is closed, the orders are executed in full if possible, or partially if a full execution would violate certain pool constraints (e.g. the ratio of junior to senior investors). Due to the computational complexity of calculating the partial order fulfillment, the calculation is made off chain and the smart contracts validate the results.

The system can additionally integrate with external credit providers (e.g. MakerDAO), allowing holders of NFTs to access credit from existing DeFi lending institutions (that are generally designed with fungible assets in mind).

In pools that are integrated with MakerDAO, Maker acts essentially as a large senior investor, holding a DROP position as collateral to enable dai minting to fund loans in the tinlake system. However, the net result of the DROP position and the dai loan of the Maker CDP is redirected to flow to TIN holders, as they bear the risk in this setting. The Maker integration can be seen as a way to leverage the performance of the Tinlake system, allowing for increased capital efficiency, while increasing TIN risk.

More in depth documentation of the intended functioning of the tinlake system can be found at https://developer.centrifuge.io/. More information about the maker adapter can be found in the assessment by the maker developers: https://forum.makerdao.com/t/ns2drp-ns-drop-mip22-token-smart-contract-domain-team-assessment/5517

Findings

Our findings are separated into three sections:

  • Bugs: issues that impact the security or correctness of the system
  • Improvements: changes that could improve the clarity, functionality, or efficiency of the system, but that do not impact security or correctness
  • Notes and Miscellanea: points of interest that do not merit an explicit recommendation for change

Bugs

Finding Severity Likelihood Addressed
B01. reserve.sol accounting can be manipulated by dai transfers High High 3c8db8e
B02. clerk.stabilityFee does not take jug.base into account High Low 1c91385
B03. clerk.sol rounding error can lead to DROP price of 0 High Low 1d54213
B04. assessor.sol senior ratio can exceed ONE Medium Low 1d54213
B05. Double counting assets between shelf.borrow and shelf.withdraw Medium High c633f7e
B06. Senior interest for the first loan in a pool can be applied twice Medium High ddee95f 695fc12
B07. Mixed usage of approximatedNAV and currentNAV in assessor.sol Low High 7cac561

B01. reserve.sol accounting can be manipulated by dai transfers

In reserve.sol, both a local balance_ variable which is incremented and subtracted at deposits and payouts, and the direct balanceOf(pot) value are being used in calculations involving the maker adapter integration: L118, L143. If someone were to send at least 1 wei dai to the reserve, balanceOf(pot) will be larger than balance_, which can lead to blocking payouts or deposits from a failing safeSub in _payoutAction, causing a system deadlock.

B02. clerk.stabilityFee does not take jug.base into account

While the maker stability fee is correctly calculated by the following expression in clerk.debt():

rmul(art, rmul(rpow(safeAdd(jug.base(), duty), safeSub(block.timestamp, rho), ONE), rateIdx));

the function clerk.stabilityFee fails to take jug.base() into account, which can lead to an incorrect calculation of the cdp debt in assessor.remainingCredit, in the worst case reverting a safeSub, blocking calls to assessor.seniorBalance() and subsequently deadlocks epoch execution.

n.b. that in practice, the jug.base() variable has always been zero since the deployment of multicollateral dai and stability fees have been adjusted on a per ilk basis.

B03. clerk.sol rounding error can lead to DROP price of 0

When due some rounding error seniorTranche.tokenSupply() is left with dust (e.g. 1 instead of 0), the check in assessor.sol#L167 during _calcSeniorTokenPrice is missed and the seniorTokenPrice can end up being 0 (in case the pool only has junior investors). This can lead to multiple issues,

  • a division by `0` at `clerk.sol#L231` where the seniorTokenPrice ends up being 0
  • when a senior investor tries to redeem they will be stuck calling disburse due to another division by zero in `tranche.sol#L167`.

This scenario only occurs when seniorTokenRatio is allowed to be `0` and the pool only consists out of junior investors.

B04. assessor.sol senior ratio can exceed ONE

It's taken to account in reBalance but the `seniorRatio` is not updated in repaymentUpdate and so it would be wrong adjusting the senior balance and debt. Needs more analysis on the borrower side.

B05. Double counting assets of loans between shelf.borrow and shelf.withdraw

The total assets held by the tinlake system at any point in time is given by reserves + NAV; idle deposits kept in the reserve and the net asset value of all outstanding loans (see https://developer.centrifuge.io/learn/understanding-tinlake/#nav for details).

However, in between the two stages of borrowing, shelf.borrow and shelf.withdraw, a loan is considered active and counted towards the NAV, while the borrowed amount still sits in the reserve, effectively being counted twice.

This poses a particular problem for pools with Maker integration, as when additional DROP are minted to provide collateral for the CDP, the seniorRatio is adjusted too far down, as assets are overvalued, leaving senior investors with too little interest accruing stake in the loan (seniorBalance becomes too low).

Luckily, this inaccuracy in the seniorRatio only remains until reserve.balance() is called, which happens at every epoch, so senior investors only miss out on a portion of one epochs interest.

B06. Senior interest for the first loan in a pool can be applied twice

The method assessor.dripSeniorDebt() applies interest on the seniorDebt, i.e. the value of of current outstanding loans that are generating interest for DROP holders.

However, the storage variable lastUpdateSeniorInterest is only updated when seniorDebt actually has increased:

if (newSeniorDebt > seniorDebt_) {
    seniorDebt_ = newSeniorDebt;
    lastUpdateSeniorInterest = block.timestamp;
}

which means that the initialization of seniorDebt does not update this variable, and a retroactive, instantaneous interest is applied since the opening of the pool.

In pools without maker integration, this scenario does not occur since the lastUpdateSeniorInterest variable is updated in borrowUpdate (of which dripSeniorDebt() is a subcall), but changes to seniorBalance as a result of changeSeniorAsset will lead to too much interest being applied.

B07. Mixed usage of approximatedNAV and currentNAV in assessor.sol

The calcSeniorTokenPrice() and calcSeniorTokenPrice() methods on the Assessor use different values for the NAV calculation. In calcSeniorTokenPrice() the approximated NAV is used, in calcJuniorTokenPrice() the current NAV is used. Callers of these methods in the Assessor may receive inconsistent results.

calcJuniorTokenPrice() is not used within tinlake. calcSeniorTokenPrice is used by the Clerk. The currentNAV in the current version of the navfeed is too expensive to use in this context, so calcJuniorTokenPrice should be modified to use the approximated NAV.

Improvements

Recommendation Implemented
I01. General lack of events a8b57d5 1706aef
I02. Return submissionsPeriod and error code in closeEpoch()  
I03. Use immutable wherever possible 6d2b21c
I04. tranche.sol: use address(this) instead of self state variable 1fb4cf2
I05. tranche.sol: calcDisburse reverse nesting in while and two if statements  
I06. Argument order of newRestrictedToken in RestrictedTokenFab 1c65cc4
I07. Missing tests for updateMembers() in memberAdmin.t.sol and poolAdmin.t.sol 6072277
I08. Misleading comments in assessor.sol  
I09. reserve.sol: Rename balance_ to totalBalance 6d2b21c
I10. Use enums for error codes in coordinator()  
I11. Unused parameters in Assessor.calcJuniorTokenPrice / Assessor.calcSeniorTokenPrice  

I01. General lack of events

Events should be used in places where a general state-change takes place, (e.g. such as file or rely calls) in order to monitor state-changes and make the history queryable.

I02. Return submissionsPeriod and error code in closeEpoch()

When calling closeEpoch() there is no indication of whether the epoch successfully executed or went into a submission period. Currently a caller will always need to call submissionsPeriod afterward. It would be more convenient if this was simply returned by the closeEpoch() method. In case the epoch cannot be closed immediately, it would be good to return the error code to inform the caller what constraint cannot be fulfilled.

I03. Use immutable wherever possible

All state variables which are assigned in the constructor and never changed should be marked as immutable. This will save gas by putting them directly in the contract code instead of in the storage and make the code more readable.

I04. tranche.sol: use address(this) instead of self state variable

The global variable address self which is set to address(this) in the constructor and never changed is redundant and more expensive then a simple address(this) statement. It should be replaced.

I05. Argument order of newRestrictedToken in RestrictedTokenFab

The argument order for RestrictedTokenFab.newRestrictedToken(string name, string symbol) is reversed from the underlying call. It should be reversed in order to prevent confusion and comply with the order defined by the ETC20 interface.

I06. Missing tests for updateMembers() in memberAdmin.t.sol and poolAdmin.t.sol

memberAdmin

poolAdmin

Both files have updateMembers() functions for testing and test names which indicate they are being used, but the pluralized version of the tests are in fact calling the singular updateMember() helpers.

I07. Redundant checks / updates in assessor.sol

  • setting lastUpdateSeniorInterest at the end of repaymentUpdate / borrowUpdate
  • repeated checks in dripSeniorDebt / chargeInterest / seniorDebt()

I08. Misleading comments in assessor.sol

Both _calcSeniorTokenPrice and _calcJuniorTokenPrice have comments stating that the maker creditline is included in the token price calculations, however this is not the case for either method.

I09. reserve.sol: Rename balance_ to totalBalance

The totalBalance method of the Reserve simply returns the value stored in the (public) balance_ storage variable. This method could be auto-generated by solidity if balance_ was renamed to totalBalance, resulting in a minor code simplification.

I10. Use enums for error codes in coordinator.

Using enums instead of just constants improves compile time guarantees and readability of the code.

I11. Unused parameters in Assessor.calcJuniorTokenPrice / Assessor.calcSeniorTokenPrice

If either function is being called with two parameters, the second parameter is ignored and defaults to reserve.totalBalance(). To prevent confusion, this parameter could be removed entirely from the function.

Notes and Miscellanea

N01. Misleading naming in deployer code

The deployer for the borrower module has an interface named NFTFeedLike, this expects an init() method to be available. However the actual NFTFeed has no such method, and the interface should instead be renamed to NAVFeedLike to reflect the true intention.

N02. Accounting in Reserve makes potentially unsafe assumptions regarding token semantics

The Reserve contract is responsible for securing and tracking the amount of loanable currency in the system. The reserve's current balance is tracked in a cached storage variable (balance_) which is updated on each call to deposit or hardDeposit. This approach is more gas efficient that checking the balance on the token directly (with a call to balanceOf), but does make some assumptions about token semantics that may not always hold. As an example, if the currency token takes a transfer fee the balance_ variable will be incorrect with respect to the actual balance of the reserve.

New currency tokens should be carefully audited to make sure that they do not violate the expectations of the Reserve.

Design Analysis

Rounding Error

Tinlake contains many locations that can introduce precision loss or numerical error into the calculations: any usage of native EVM division, or of the fixed point multiplication (rmul) and division (rdiv) operations. It is the opinion of the audit team that insufficient attention has been given to the potential for the precision loss introduced by these operations to negatively impact the pool.

Instead of a hard failure in cases where numeric errors result in an arithmetic underflow (e.g. when attempting to transfer away more tokens than the pool has available), tinlake modifies the values in question so that they no longer underflow (e.g. via the safeTotalSub and _safeTransfer methods). Although their usage may allow execution to continue in the face of precision loss, they also hide the presence of such errors, and allow them to propagate (and potentially accumulate or compound), potentially resulting in unexpected or dangerous behaviour as the system continues to operate with erroneous information.

Although several issues relating to rounding error were uncovered during the course of the audit, time constraints meant that a full analysis of all possible sources of numeric error and their potential impact has not been carried out.

The audit teams recommended approach to rounding analysis would be to first decide on a set of desirable system properties relating to rounding error (e.g. "When executing a redeem order, numerical error should always be in favour of the investors remaining in the pool"). Once a comprehensive set of properties has been defined, each source of numerical error can be analysed in turn to ensure that these properties have been upheld. For an example of such an analysis, you can refer to the section on numerical error in the uniswap-v2 audit report.

As a final note, the direction of rounding in rdiv changes depending on the size of the remainder, rounding up for cases where the remainder >= 0.5, and down otherwise. While this makes intuitive sense, it may complicate the analysis described above, and the development team may wish to consider replacing it with a fixed point division routine that has a fixed direction of rounding.

Epochs and Transaction Ordering

Tinlake makes use of an epoch mechanism to provide resistance against some forms of strategic transaction ordering. Without the epoch mechanism, a user with DROP in the pool could for example redeem their DROP directly after another user has supplied currency to the pool, or submit a supply order to an oversubscribed pool directly after another participant has redeemed. This would give sophisticated users an advantage, and would likely result in a marketplace for the right to sequence supply / redeem transactions in the most advantageous positions (e.g. via flashbots), inflating the cost of successful interaction with the pool. The epoch mechanism additionally attempts to fairly allocate a portion of the desired funds to all participants for epochs where it is impossible to fully execute all orders.

As the epoch mechanism is applied to lenders only, borrowers can still gain an advantage by sequencing their borrows directly after epoch close in cases where there is more demand for loans than available credit.

The fair distribution property can also be gamed for epochs where only a partial order fulfillment is possible:

  1. In the case that all redeem orders cannot be fulfilled in a given epoch, lenders who wish to redeem only a portion of their position can gain an advantage over "honest" pool participants by submitting a redeem order for a larger amount than that which they actually wish to receive. For pools where lenders have a high confidence in being able to reenter the pool, it is probably a strictly better strategy to simply always submit a redeem order for the full amount, and then resupply in the next epoch.
  2. In the case that all supply orders cannot be fulfilled in a given epoch, lenders who wish to enter a pool can gain an advantage by submitting a supply order with a larger amount that they actually wish to supply.

It is also worth noting that since all participants will have an increasingly certain expectation of the results of epoch execution as the epoch proceeds, it is always generally advantageous to submit orders as close to epoch close as possible.

The above issues could be eliminated by:

  • introducing a two stage commit/reveal scheme to the epoch process
  • enforcing commit / reveal & epochs for the borrower interactions

Both of these measures would introduce significant additional overhead to system interactions, and the costs may outweigh the benefits.

Collusion resistance

Being a platform for real world assets, loans in tinlake differ from traditional defi platforms in that they do not require on chain collateral but are rather legally enforced.

However, in some scenarios it is still possible borrowers to extract value from the system while repaying their loans by colluding with investors.

DROP & borrower collusion

As there can be multiple asset classes with different risk profiles in a Tinlake pool, it is possible to have loans whose interest rates are lower than DROP returns. In such settings, asset originators can use this in their favor and borrow money and provide senior investments with borrowed funds. As long as the system can sustain their DROP yields (from other, higher interest loans), this provides a net yield for these actors, in effect extracted from TIN holders in the system.

TIN & borrower collusion

Borrowers can also collude with a portion of the TIN investors to extract value from other TIN holders by performing the following scenario:

  1. Before borrowing, acquire TIN by supplying an order and wait for the epoch to settle.
  2. Take out a large loan, increasing the NAV of the system, and therefore TIN value.
  3. Cash out TIN position at higher price (requires sufficient funds in the reserve).
  4. Repay loan prematurely, before too much interest has accrued.

This attack is essentially possible since NAV calculations assumes loans will be paid back with interest in the future, and not be paid back prematurely.

Governance Powers

The Tinlake system as currently deployed is operated in a centralized manner, with a multisig having the power to make or break arbitrary auth connections throughout the system. This gives the multisig significant powers over the system, including the ability to take all reserves from the system (either via Tranche.authTransfer, or via a call to tranche.mint, followed by the submission of a redeem order).

For pools that have been integrated with MakerDAO, the governance multisig can draw dai up to the Maker debt ceiling for that pool by creating and approving a fake NFT with a very high value, and then borrowing against this NFT.

It should be noted that there is currently no time delay imposed on governance actions.

Users of and integrators with tinlake should be aware of the risk of and potential catastrophic impact of a multisig compromise. The address of the root multisig is: 0xf3BceA7494D8f3ac21585CA4b0E52aa175c24C25.

Contract Map

Appendix A. Bug Classifications

Severity  
informational The issue does not have direct implications for functionality, but could be relevant for understanding.
low The issue has no security implications, but could affect some behaviour in an unexpected way.
medium The issue affects some functionality, but does not result in economically significant loss of user funds.
high The issue can cause loss of user funds.
Likelihood  
low The system is unlikely to be in a state where the bug would occur or could be made to occur by any party.
medium It is fairly likely that the issue could occur or be made to occur by some party.
high It is very likely that the issue could occur or could be exploited by some parties.