Untangled Protocol Audit Report

Copyright © 2022 by Verilog Solutions. All rights reserved.

June 3, 2022

by Verilog Solutions

This report presents our engineering engagement with the Untangled Finance team on the Untangled Protocol, a digital securitization platform bridging real-world assets to decentralized finance.

Project NameUntangled Protocol
Repository Linkhttps://github.com/untangledfinance/untangled-protocol-open
Commit Hash8333a14e6497efd824a425d5bf4dc7c243fed5e6
Language Solidity
ChainCelo

About Verilog Solutions

Founded by a group of cryptography researchers and smart contract engineers in North America, Verilog Solutions elevates the security standard for Web3 ecosystems by being a full-stack Web3 security firm covering smart contract security, consensus security, and operational security for Web3 projects.

Verilog Solutions team works closely with major ecosystems and Web3 projects and applies a quality above quantity approach with a continuous security model. Verilog Solutions onboards the best and most innovative projects and provides the best-in-class advisory service on security needs, including on-chain and off-chain components.

Table of Contents

Service Scope

Service Stages

Our auditing service includes the following two stages:

  1. Pre-Audit Consulting Service
    • As a part of the pre-audit service, the Verilog team worked closely with the project development team to discuss potential vulnerability and smart contract development best practices. Verilog team is very appreciative of establishing an efficient and effective communication channel with the project team, as new findings are often exchanged promptly and fixes were deployed quickly, during the preliminary report stage.
  1. Smart Contract Auditing Service
    • Verilog Solutions team analyzed the entire project using a detailed-oriented approach to capture the fundamental logic and suggested improvements to the existing code. Details can be found under Findings & Improvement Suggestions

Methodology

  • Code Assessment
    • We evaluate the overall quality of the code and comments as well as the architecture of the repository.
    • We help the project dev team improve the overall quality of the repository by providing suggestions on refactorization to follow the best practice of Web3 software engineering.
  • Code Logic Analysis
    • We dive into the data structures and algorithms in the repository and provide suggestions to improve the data structures and algorithms for the lower time and space complexities.
    • We analyze the hierarchy among multiple modules and the relations among the source code files in the repository and provide suggestions to improve the code architecture with better readability, reusability, and extensibility.
  • Business Logic Analysis
    • We study the technical whitepaper and other documents of the project and compare its specification with the functionality implemented in the code for any potential mismatch between them.
    • We analyze the risks and potential vulnerabilities in the business logic and make suggestions to improve the robustness of the project.
  • Access Control Analysis
    • We perform a comprehensive assessment of the special roles of the project, including their authorities and privileges.
    • We provide suggestions regarding the best practice of privilege role management according to the standard operating procedures (SOP).
  • Off-Chain Components Analysis
    • We analyze the off-chain modules that are interacting with the on-chain functionalities and provide suggestions according to the SOP.
    • We conduct a comprehensive investigation for potential risks and hacks that may happen on the off-chain components and provide suggestions for patches.

Audit Scope

Project Summary

Untangled Protocol is a decentralized lending and liquidity protocol for real-world asset collaterals.

Below is a graph explaining the connections and relations between contracts:


  • Asset Pool: a new instance of SecuritizationPool(Asset Pool) can be created by the approved pool creator. The pool purchases assets with its reserve in stablecoins. Asset Pool borrows from Liquidity Pool (automatically) and Pool Backers (manually) (collectively called Funders) in order to purchase Assets from Originator. The interest of Funders within an Asset Pool could be represented by a Trustee, an independent and trusted third party in the real world.
  • Liquidity Pool: a smart contract that receives stablecoins from Liquidity Providers and automatically lends to an Asset Pool once its minimum first loss has been reached i.e. the Asset has enough Backers, in exchange for Asset Pool’s SOTs. A Liquidity Pool can lend to many Asset Pools.
  • Borrower: an entity that borrows from Asset Pool and uses the proceeds to develop a project, e.g. sustainability-linked loans where disbursement is made on-chain and rewards are available for achieving certain sustainability targets.
  • Assets: each Asset purchased by Asset Pool is represented by an NFT
    • A yield asset (such as a green project loan) is represented by LAT
    • A non-yield asset (such as a trade finance invoice) is represented by AIT
  • Asset Pool Tranches:
    • Senior tranche financing is represented by SOT, an ERC-20 token with the currency value of 1 but is continuously compounded with interest.
    • Junior tranche financing is represented by JOT, an ERC-20 token acting as the first loss piece in an Asset Pool.

Findings & Improvement Suggestions

Severity TotalAcknowledged Resolved
High 5 5 5
Medium 3 3 3
Low 6 6 6
Informational 6 6 5

High

  1. Inconsistency in the documentation and actual implementation
    SeverityHigh
    Sourcepool/SecuritizationManager.sol#L51;
    StatusResolved in commit a41e9eeef21ae4e603d044ed3621b1c1461cd911;
    • Description

      Anyone can create new pools while the Untangled documents state that only the approved entity can create new pools. A malicious pool can be created by an attacker to drain the user’s funds.

      1. In the Untangled Platform App User Guide, it states that only Originator/Issuer can create a new pool:
        “Business users who have been designated as Originator/Issuer can create a new pool under Portfolio/Pool”
      1. In the Untangled Whitepaper, it states that those who are approved by governance can create a new pool:
        “Anyone approved by Governance can create a Borrowing Pool …”

      However, in the code implementation, there is no access control on function SecuritizationManager.newPoolInstance() and anyone can create new pool instances by calling this function. Also, there is no check against who creates the pool in other components that interact with pools. Anyone can create a pool with his/her own desired settings for the pool(e.g. set the currency token of the pool or modify the pot address of the pool).

      Though attacking surfaces are limited because the pool implementation is set by admin roles. The code logic of the pool cannot be modified and only a new pool instance can be created. However, an attacker can create a pool with a malicious currency token. The pool and related contracts only work properly under the assumption that the currency token is a normal ERC20 token. Because of the malicious token breaking the assumption, the pool and all the related components will work improperly, which would put users’ funds at risk.

      Besides, based on the documents, all pools can only be created by trusted entities with verified ERC721 tokens representing real-world assets. Users might assume the pool created by the attacker is also a trusted pool and interact with it. Pool settings can be configured by the pool owner(attacker). An attacker can set the pot that is used to receive currency tokens with his/her own address or set an arbitrary interest rate.

    • Exploit Scenario

      Attacker Mallory creates a pool with non-verified ERC721 assets and the pool accepts USDC as currency. Mallory sets the pot that is used to collect currency tokens with his own address. User Alice assumes the pool created by Mallory is an official and trusted pool created by Untangled Finance team and interacts with this malicious pool. USDC tokens are transferred to the pot and Mallory withdraws all the USDC stored in the pot. Alice loses her funds.

    • Recommendations

      We recommend adding an access control for the SecuritizationManager.newPoolInstance() according to the design stated in the Untangled documents.

      Such an access control ensures that only designated roles or accounts can create new pool instances. With only pools created by trusted parties, the overall protocol safety level will be greatly improved.

    • Results

      Resolved in commit a41e9eeef21ae4e603d044ed3621b1c1461cd911. Untangled Finance team added a new role POOL_CREATOR and only POOL_CREATEOR can create a new pool instance.

      A new privileged role was introduced in this fix commit. We recommend modifying the instructions and explanations on Untangled documents accordingly.

  1. Lack of access control on critical functions
    SeverityHigh
    Sourceprotocols/note-sale/crowdsale/TimedCrowdsale.sol#L45;
    StatusResolved in commit d570a6e950d6c0ab641ece08fc657a5ccad606ee;
    • Description

      There is no access control on a function that can change critical state variables. Anyone can call this function to affect the status of the sale round.

      Function TimedCrowdsale.newSaleRoundTime() sets openingTime and closingTime of current sale round. They are two very critical state variables that can determine the status of the current sale round (whether the sale is open or closed). Ideally, only owner roles can extend the sale round by calling the function TimedCrowdsale.extendTime(), which is a function that only can be called by OWENR_ROLE. However, anyone can just call TimedCrowdsale.newSaleRoundTime() to bypass the OWNER_ROLE check and reset theclosingTime.

      Any user can determine the status of the sale, which violates the design that only privileged roles like OWNER_ROLE can modify the status of the sale round. Users can just stop or reopen or extend the sale round themselves by calling this function. Token sales will be interrupted and sales amount will be unexpected, which can have a great impact on pool shares distribution and hurt token holders’ interest.

    • Exploit Scenario

      The token sale ends and attacker Mallory sets the closingTime through the public function TimedCrowdsale.newSaleRoundTime() to reopen the sale and buy more tokens. More tokens are sold than expected, which dilutes the existing token holder’s share and value.

    • Recommendations

      Add access control to function TimedCrowdsale.newSaleRoundTime().

      Access control on this function ensures that only approved roles have the ability to modify the two critical variables through this function. Token sales status can no longer be modified by unauthorized users.

    • Results

      Resolved in commit d570a6e950d6c0ab641ece08fc657a5ccad606ee. An onlyRole modifier has been added to the function.

      Same as functionTimedCrowdsale.extendTime(), TimedCrowdsale.newSaleRoundTime() can only be called by OWNER_ROLE. Sale round status can only be modified by OWNER_ROLE, no more unexpected interruptions on token sales from unauthorized users.

  1. Inconsistency in the function name and actual implementation
    SeverityHigh
    Sourceprotocols/note-sale/crowdsale/Crowdsale.sol#L108;
    StatusResolved in commit d570a6e950d6c0ab641ece08fc657a5ccad606ee;
    • Description

      The name of function getTimeStartEarningInterest() indicates that it should return time starts earning interest instead of block.timestamp. Incorrect implementation eventually affects the calculation of the long sale token amount that the user can buy.

      According to the name of function Crowdsale.getTimeStartEarningInterest(), it should return the starting earning interest time. However, in the actual implementation, this function returns block.timestamp. This function is used in getLongSaleTokenPrice() to return the long sale token price, which is eventually used in the calculation of the long sale token amount that a user can buy. The getLongSaleTokenPrice() reverts if getTimeStartEarningInterest() returns zero value and user will not be able to buy tokens.

      Under current implementation, getTimeStartEarningInterest() will always return block.timestamp which is always non-zero, thus it will never cause require statement in getLongSaleTokenPrice() to revert. There will be no difference with or without the check.

      The wrong implementation of the function located in the base contract will affect the proper operations of all other functions that rely on it. Under current code implementation, users are able to buy tokens regardless of the time starts earning interest that Untangled protocol wishes to set. An unexpected token amount will be sold to users without the correct setting on the starting earn interest time.

    • Exploit Scenario

      The time starts earning interest cannot be set and user Alice can always buy tokens from the token sale contracts regardless of the actual start earning interest time that Untangled protocol wishes to set. Alice ends up obtaining more note tokens than she should.

    • Recommendations

      Check and rewrite the implementation code of function Crowdsale.getTimeStartEarningInterest().

      Correct implementation can ensure other functions that rely on this function works properly.

    • Results

      Resolved in commit d570a6e950d6c0ab641ece08fc657a5ccad606ee. function Crowdsale.getTimeStartEarningInterest() was removed from base contract Crowdsale.sol and was replaced by the variable timeStartEarningInterest exists in the contract MintedIncreasingInterestTGE.sol and MintedNormalTGE.sol, which would only have limited effect inside the contract and contracts that inherit it.

      The value of timeStartEarningInterest can be set inside function setupLongSale(). The require statement will revert if the value is not set correctly. Variable timeStartEarningInterest works properly as expected.

  1. Inconsistency in business logic and actual implementation
    SeverityHigh
    Sourcepool/base/SecuritizationPoolServiceBase.sol#L18, #L32;
    StatusResolved in commit a2909c0ac25e76953fa3fd0ee42b667619e8cf23;
    • Description

      Functions used to convert token values are implemented with incorrect equations. It would result in incorrect calculation of note token values and users’ share proportion in the pool, which directly affects the proper operations of the whole protocol.

      SecuritizationPoolServiceBase.convertTokenValueToCurrencyAmount() and SecuritizationPoolServiceBase.convertCurrencyAmountToTokenValue() convert token values from one token to another token by comparing decimal differences of the two tokens. However, the equations used to convert token values are incorrect. converted token value should be 10**(bigger_decimal- smaller_decimal) rather than 10**bigger_decimal- smaller_decimal .

      These two functions are used in various important operations used for calculating token price and token value such as DistributionAssessor.getSOTTokenPrice() and SecuritizationPoolValueService.getExpectedERC20AssetValue(). It eventually affects the calculation of note token value and users’ share proportion in the pool. Incorrect implementation of these two critical functions can result in incorrect calculations of the number of all the users’ funds and yields and loans in the pool.

    • Exploit Scenario

      User Alice’s note token value is calculated incorrectly and it’s far less than it should be.

    • Recommendations

      Change the function implementation to the following suggestions:

      function convertTokenValueToCurrencyAmount(
              address pool,
              address tokenAddress,
              uint256 tokenValue
          ) internal view returns (uint256) {
              uint256 currencyDecimals = ERC20(ISecuritizationPool(pool).underlyingCurrency()).decimals();
              uint256 tokenDecimals = ERC20(tokenAddress).decimals();
      
              return
                  currencyDecimals > tokenDecimals
                      ? tokenValue * (10**(currencyDecimals - tokenDecimals))
                      : tokenValue / (10**(tokenDecimals - currencyDecimals));
          }
      
          function convertCurrencyAmountToTokenValue(
              address pool,
              address tokenAddress,
              uint256 currencyAmount
          ) internal view returns (uint256) {
              uint256 currencyDecimals = ERC20(ISecuritizationPool(pool).underlyingCurrency()).decimals();
              uint256 tokenDecimals = ERC20(tokenAddress).decimals();
      
              return
                  currencyDecimals > tokenDecimals
                      ? currencyAmount / (10**(currencyDecimals - tokenDecimals))
                      : currencyAmount * (10**(tokenDecimals - currencyDecimals));
          }
  1. Inconsistency in business logic and actual implementation
    SeverityHigh
    Sourcepool/DistributionAssessor.sol#L13;
    StatusResolved in commit 0ee18c763347a7ade21528d47b7cfc765fda0be9;
    • Description

      getSOTTokenPrice() only returns the accumulated interest, without adding the default initial price. Incorrect SOT token price causes the currency redemption amount calculation incorrect and users’ shares of the pool are distributed unfairly.

      SOT token price consists of two parts. It increases from the default price and it will be bigger than the default token price with accumulated interest. However, function DistributionAssessor.getSOTTokenPrice() directly returns the interest part calculated by function _calculateInterestForDuration(). The token default price is not added on top of the calculated interest.

      This incorrect calculation results in all the calculated SOT token prices being smaller than they should be. SOT token price stands for the share proportion of users owned in the pool. Users can redeem more funds than they should. Users who redeem early can redeem more funds than they should and there might not be enough funds for users who redeem late. This issue puts funds in the pool at great risk.

    • Exploit Scenario

      User Alice makes the redeem request while the pool still has enough funds and she receives more tokens than she should.

    • Recommendations

      Add the initial token price to the return value of the cumulated interest in function getSOTTokenPrice() to make sure token prices are calculated correctly and funds in the pool are distributed fairly to note token holders.

      function getSOTTokenPrice(address pool, uint256 timestamp) public view override returns (uint256) {
              ISecuritizationPool securitizationPool = ISecuritizationPool(pool);
              ERC20 noteToken = ERC20(securitizationPool.sotToken());
      
              if (address(noteToken) == address(0) || noteToken.totalSupply() == 0) return 0;
              uint256 ONE_SOT_DEFAULT_PRICE = convertTokenValueToCurrencyAmount(
                  address(securitizationPool),
                  address(noteToken),
                  1 * 10**uint256(noteToken.decimals())
              );
              uint256 openingBlockTimestamp = securitizationPool.openingBlockTimestamp();
      
              if (timestamp < openingBlockTimestamp) return ONE_SOT_DEFAULT_PRICE;
      
              uint32 interestRateSOT = securitizationPool.interestRateSOT();
      
              return ONE_SOT_DEFAULT_PRICE + _calculateInterestForDuration(ONE_SOT_DEFAULT_PRICE, interestRateSOT, timestamp - openingBlockTimestamp);
          }

Medium

  1. Inconsistency in business logic and actual implementation
    SeverityMedium
    Sourcepool/SecuritizationPoolValueService.sol#L36, #L122;
    StatusResolved in commit b69d2226bd47b296e80b86c309262f6086efb34c;
    • Description

      Expected asset values are calculated incorrectly due to incorrect calculation of total debt. It would affect users and frontend implementation when querying information with this function.

      Function SecuritizationPoolValueService.getExpectedAssetValue() and SecuritizationPoolValueService.getExpectedERC20AssetValue() are two functions which calculate the expected ERC721/ERC20 asset value. These two functions should have similar logic on implementation. Expected value are calculated with total debt and present value. However, the totalDebt in SecuritizationPoolValueService.getExpectedAssetValue() is calculated incorrectly. It should be calculated with timestamp, instead of expirationTimestamp.

      When the debt is overdue, the computed user’s debt will be smaller than expected. When the debt is not overdue, the computed user’s debt will be bigger than expected. In Either way, the user’s debt is not calculated correctly, thus it will affect the calculation of the expected asset value.

      Public functiongetExpectedAssetValue() is currently only used in an external view function and not referenced by any other contracts. Incorrect asset value will be given when querying from the protocol, which might affect the frontend implementation. Pool funds will not be affected by the incorrect calculation.

    • Exploit Scenario

      Incorrect expected asset values are given to users or used in the frontend when querying this function.

    • Recommendations

      Calculate the total debt with timestamp instead of expirationTimestamp.

      function getExpectedAssetValue(
              address poolAddress,
              address tokenAddress,
              uint256 tokenId,
              uint256 timestamp
          ) public view returns (uint256) {
              IUntangledERC721 loanAssetToken = IUntangledERC721(tokenAddress);
              uint256 expirationTimestamp = loanAssetToken.getExpirationTimestamp(tokenId);
      
              uint256 overdue = timestamp > expirationTimestamp ? timestamp - expirationTimestamp : 0;
              uint256 totalDebt = loanAssetToken.getTotalExpectedRepaymentValue(tokenId, timestamp);
      
              uint256 presentValue = getPresentValueWithNAVCalculation(
                  poolAddress,
                  totalDebt,
                  loanAssetToken.getInterestRate(tokenId),
                  loanAssetToken.getRiskScore(tokenId),
                  overdue,
                  loanAssetToken.getAssetPurpose(tokenId)
              );
      
              if (timestamp < expirationTimestamp) {
                  totalDebt = loanAssetToken.getTotalExpectedRepaymentValue(tokenId, timestamp);
              }
      
              return presentValue < totalDebt ? presentValue : totalDebt;
          }
  1. Lack of state assertion on critical functions
    SeverityMedium
    Sourceprotocols/loan/LoanInterestTermsContract.sol#L134;
    StatusResolved in commit 0857c34e2cf88e447205d7b779a64196c35dd0c5;
    • Description

      Missing loan status check on function LoanInterestTermsContract.registerRepayment() allows loan repayments to be made on loans that are already concluded or not started. Unnecessary Repayments on inadequate loans can interfere the loan management and cause loss of user funds.

      LoanInterestTermsContract.registerRepayment() is called in function LoanRepaymentRouter._doRepay() to help users to make loan repayments. However, both of the functions miss the loan status check. Loan Repayments can be made on loans that are already concluded or not started.

      Loans that are concluded or not started should not be allowed for accepting repayments. Loan repayments on loans with incorrect status allow users to make unnecessary loan repayments, thus it results in loss of funds.

    • Exploit Scenario

      Alice accidentally makes a repayment on the loan that has already concluded. She lost the part of funds that are used to make repayments.

    • Recommendations

      Add loan status check on LoanInterestTermsContract.registerRepayment() to filter out loans with incorrect status.

      It can prevent loss of funds due to repayments on inadequate loans.

  1. Inconsistency in the function name and actual implementation
    SeverityMedium
    Sourceprotocols/note-sale/fab/NoteTokenFactory.sol#L52, #59
    StatusResolved in commit 9ed09bcf4eb60e975ec9cbc403f2fb9d5bab5a26;
    • Description

      The function name indicates it should be able to pause and unpause tokens. However, they can only pause tokens and tokens cannot be unpaused once they are paused. The system can not go back to normal operations once tokens are paused.

      As the names of function NoteTokenFactory.pauseUnpauseToken() and NoteTokenFactory.pauseUnpauseAllTokens() indicates, it should be able to pause and unpause tokens. However, under the current implementation, they can only be used to pause tokens. Paused tokens can result in failures of protocol operations which are heavily dependent on the note tokens. And there are no other functions that are designed to unpause the paused token. This critical operation is irreversible and will result in the failure of the whole protocol.

      function pauseUnpauseToken(address tokenAddress) external whenNotPaused nonReentrant onlyRole(DEFAULT_ADMIN_ROLE) {
      	require(isExistingTokens[tokenAddress], 'NoteTokenFactory: token does not exist');
      	NoteToken token = NoteToken(tokenAddress);
      	if (token.paused()) token.unpause(); // redundant unpause
      	token.pause();
      }
      function pauseUnpauseAllTokens() external whenNotPaused nonReentrant onlyRole(DEFAULT_ADMIN_ROLE) {
      	for (uint256 i = 0; i < tokens.length; i++) {
      		if (tokens[i].paused()) tokens[i].unpause();
      		else tokens[i].pause();
      	}
      }
    • Exploit Scenario

      Admin accidentally calls the function to pause all tokens and the whole system cannot work properly because of the paused note tokens.

    • Recommendations

      Rewrite these two functions, so they can pause or unpause tokens.

Low

  1. Mismatched array length
    SeverityLow
    Sourcepool/SecuritizationPool.sol#L189, #L219;
    StatusResolved in commit 0e12cc6adf8eea62639a823ec8284334cb1ee2a6;
    • Description

      The number of times the for loop would run in withdrawAssets() and withdrawERC20Assets() functions are based on one of its input array lengths, therefore, if the decisive parameter is input wrong, these two functions can still run without error but not as the user expected.

      Shown as the code below, if the for loop decisive parameter tokenIds is input wrong by mistake, the recipients will not be able to receive their tokens. And more importantly, if the tokenIds and recipients is mismatch, token will be transferred to the wrong recipient.

      function withdrawAssets(
              address[] calldata tokenAddresses,
              uint256[] calldata tokenIds,
              address[] calldata recipients
          ) external override whenNotPaused nonReentrant onlyRole(OWNER_ROLE) {
              for (uint256 i = 0; i < tokenIds.length; i++) {
                  require(removeNFTAsset(tokenAddresses[i], tokenIds[i]), 'SecuritizationPool: Asset does not exist');
                  IUntangledERC721(tokenAddresses[i]).safeTransferFrom(address(this), recipients[i], tokenIds[i]);
              }
          }
    • Exploit Scenario

      N/A

    • Recommendations

      Check if the input arrays’ lengths are equal.

  1. Inconsistency in variable names and actual implementation
    SeverityLow
    Sourcetokens/ERC721/invoice/AcceptedInvoiceToken.sol#L99;
    StatusResolved in commit b5d7c9fb3283902bccc8ace0210a8d7495021554;
    • Description

      Fiat amount instead of the repaid amount is updated when a loan is not fully repaid and repaid amount only gets updated upon full repayments. Updates of these two state variables are confusing and do work in the way that their variable names indicate.

      The repaid amount of the AIT token only gets updated upon the full repayment of the loan. When a loan is not fully repaid, the fiat amount instead of the repaid amount is updated.

      The way how these two variables get updated does not work as their names indicate. Fiat amount should be a variable that shows how much the total loan amount is in total and it should stay unchanged on repayments. The repaid amount should be a variable that keeps track of how many repayments users have made. Right now, the fiat amount gets deducted every time when a repayment is made and repaid amount only gets updated upon full repayments. Decreasing of fiat amount loses its function to record the total loans that users owe. The names of these two variables are confusing and do not reflect how they work in the protocol.

    • Exploit Scenario

      Protocol fails to obtain the total loan data due to the deduction on fiat amount every time a repayment is made.

    • Recommendations

      Update the repay amount whenever the repayment is made Instead of the fiat amount. In this way, the system can keep a correct track of repaid amounts and total fiat amounts.

    • Results

      Resolved in commit b5d7c9fb3283902bccc8ace0210a8d7495021554. The paid amount fiat amount gets update upon repayments. Current fix is:

      if (fiatAmountRemain == 0) {
          metadata.paidAmount += payAmounts[i];
          super._burn(tokenIds[i]);
      } else {
          metadata.paidAmount += payAmounts[i];
      }

      The code can be optimized:

      metadata.paidAmount += payAmounts[i];
      if (fiatAmountRemain == 0) {
          super._burn(tokenIds[i]);
      }

  1. Lack of function virtual modifier
    SeverityLow
    Sourceprotocols/note-sale/crowdsale/Crowdsale.sol;
    StatusResolved in commit b7bb0e71577d153766452d83968b06f4638ab8fe;
    • Description

      The function names do not reflect their actual behaviors. It confuses both users and future developers. After deep dive into the inheritance structure, we think the function shown below should be virtual .

      • isLongSale() returns false, which should return a correct status of whether this crowd sale is a long sale. If this function is not meant to be implemented in the crowd sale base class, mark it as virtual and implement it later. Otherwise, remove isLongSale() in Crowdsale.sol
      • getLongSaleTokenAmount() returns the rate state variable. The rate state variable indicates the exchange rate between selling token <> stable coins (with 10^4 decimals), but the function name indicates it returns the token amount of the sale. If this function is not meant to be implemented in the crowd sale base class, mark it as virtual and implemented it later. Otherwise, remove getLongSaleTokenAmount() in Crowdsale.sol
    • Exploit Scenario

      N/A

    • Recommendations

      Mark the base class function as a virtual

  1. Unused functions
    SeverityLow
    Sourcepool/DistributionOperator.sol#L76; pool/SecuritizationPool.sol#L351, L362; protocols/loan/LoanRegistry.sol#L133;
    StatusResolved in commit 9ed09bcf4eb60e975ec9cbc403f2fb9d5bab5a26;
    • Description
      • function DistributionOperator._distributeJOTInBatch() is never used.
      • SecuritizationPool.increasePaidInterestAmountSOT() and SecuritizationPool.increasePaidPrincipalAmountSOT() are two functions only can be called by onlyDistributionOperator(). But they are not called by DistributionOperator.
      • Function setCompletedLoan is defined as onlyLoanKernel, but the contractLoanKernel does not call it.

      Unused functions waste gas when deploying the contract.

    • Exploit Scenario

      N/A

    • Recommendations

      Remove unused functions.

  1. Unreachable Code
    SeverityLow
    Sourceprotocols/loan/LoanInterestTermsContract.sol#L175;
    StatusResolved in commit fc46523db3fb60ab655ff2ed406db023d2498f00;
    • Description

      There is a if-else path that can never be reached.

      When expectedInterest == 0, the statement unitsOfRepayment < expectedPrincipal will always be true because unitsOfRepayment < expectedPrincipal + expectedInterest. So the path where is stated above can never be reached. This part of code can be removed.

      if (unitsOfRepayment >= expectedPrincipal + expectedInterest) {
          _setCompletedRepayment(agreementId);
          _addRepaidInterestAmount(agreementId, expectedInterest);
          _addRepaidPrincipalAmount(agreementId, expectedPrincipal);
          remains = unitsOfRepayment - (expectedPrincipal + expectedInterest);
      } else {
          if (expectedInterest == 0) {
      				// == Start of Unreachable Code ==
      				// since unitsOfRepayment < expectedPrincipal + expectedInterest
      				// && expectedInterest == 0
      				// so unitsOfRepayment < expectedPrincipal is for sure
              if (unitsOfRepayment >= expectedPrincipal) {
                  _addRepaidPrincipalAmount(agreementId, expectedPrincipal);
                  remains = unitsOfRepayment - expectedPrincipal;
              }
      				// == End of Unreachable Code ==
      				else {
                  _addRepaidPrincipalAmount(agreementId, unitsOfRepayment);
              }
      }
    • Exploit Scenario

      N/A

    • Recommendations

      Remove the unreachable code stated above.

  1. Redundant code
    SeverityLow
    Sourcepool/SecuritizationPool.sol; pool/DistributionTranche.sol; protocols/loan/LoanInterestTermsContract.sol; protocols/note-sale/MintedIncreasingInterestTGE.sol;
    StatusResolved in commit 7d2e07e06c3de02ac39a9a880228ac0bbf185d93;
    • Description

      Functions that have no external calls / only atomic operation need no Reentrancy Guard. Unnecessary Reentrancy Guard wastes gas.

      1. SecuritizationPool.sol increaseLockedDistributeBalance() no external calls
      1. DistributionTranche.sol redeemToken() only atomic operation
      1. LoanInterestTermsContract.sol registerTermStart() no external calls + only atomic operation
      1. SecuritizationPool.sol setupRiskScores no external calls
      1. MintedIncreasingInterestTGE.sol setYield() no external calls
    • Exploit Scenario

      N/A

    • Recommendations

      Remove no need Reentrancy Guard to save gas.

Informational

  1. Floating solidity pragma version
    SeverityInformational
    SourceUntangle protocol
    StatusAcknowledged
    • Description

      Current smart contracts use ^0.8.0. And compilers within versions≥ 0.8.0 and <0.9.0 can be used to compile those contracts. Therefore, the contract may be deployed with a newer or latest compiler version which generally has higher risks of undiscovered bugs.

      It is a good practice to fix the solidity pragma version if the contract is not designed as a package or library that will be used by other projects or developers.

      Reference: SWC-103.

    • Exploit Scenario

      N/A

    • Recommendations

      Use the fixed solidity pragma version.

    • Results

      Acknowledged. The untangled team acknowledged that the solidity version should be fixed. However, for the convenience of future upgrades, they decided that just set the solidity compiler version in hardhat config files.

  1. State variable initialization in the wrong place
    SeverityInformational
    Sourceprotocols/note-sale/crowdsale/Crowdsale.sol;
    StatusResolved in commit 9ed09bcf4eb60e975ec9cbc403f2fb9d5bab5a26;
    • Description

      State variables defined in crowdsale.sol are initialized in MintedNormalTGE.sol and MintedIncreasingInterestTGE.sol. The misplaced initialization confuses users and future developers.

      • registry: defined in crowdsale.sol but initialized in MintedNormalTGE.soland MintedIncreasingInterestTGE.
      • pool: defined in crowdsale.sol but initialized in MintedNormalTGE.soland MintedIncreasingInterestTGE.
      • token: defined in crowdsale.sol but initialized in MintedNormalTGE.soland MintedIncreasingInterestTGE.
      • currency: defined in crowdsale.sol but initialized in MintedNormalTGE.soland MintedIncreasingInterestTGE.
    • Exploit Scenario

      N/A

    • Recommendations

      Initialize the state variables in the class where it was defined.

  1. Naming issues
    SeverityInformational
    SourceUntangled protocol
    StatusResolved in commit f83a44d8fc6be599c9ee3f68877ad0f855d500ae;
    • Description

      The coding style is inconsistent. Inconsistent coding style reduces code readability.

      Some private/internal function names have underscore prefixes and some don’t. Therefore, co-developers may use the wrong function because of the Inconsistent coding style and has a bigger chance to write an insecure smart contract.

    • Exploit Scenario

      N/A

    • Recommendations

      We suggest all function names of private/internal functions contain an underscore as a prefix for better readability and coding style practice.

  1. Non-descriptive error message
    SeverityInformational
    Sourceprotocols/loan/LoanKernel.sol#L18; protocols/loan/LoanInterestTermsContract.sol#L79;
    StatusResolved in commit 9ed09bcf4eb60e975ec9cbc403f2fb9d5bab5a26 and commit 317baf65131faf60d9c97d86066d02760f896907;
    • Description

      Shown as code below, the error message provided byvalidFillingOrderAddresses is unclear. Therefore, it is difficult for a user or developer to locate the error when the unclear error messages occurs.

      modifier validFillingOrderAddresses(address[] memory _orderAddresses) {
        require(_orderAddresses[uint8(FillingAddressesIndex.CREDITOR)] != address(0x0), 'PARAM1');
        require(_orderAddresses[uint8(FillingAddressesIndex.REPAYMENT_ROUTER)] != address(0x0), 'PARAM4');
        require(_orderAddresses[uint8(FillingAddressesIndex.TERM_CONTRACT)] != address(0x0), 'PARAM5');
        require(_orderAddresses[uint8(FillingAddressesIndex.PRINCIPAL_TOKEN_ADDRESS)] != address(0x0), 'PARAM2');
        _;
      }
      modifier onlyHaventStartedLoan(bytes32 agreementId) {
          require(!startedLoan[agreementId], 'LOAN1');
          _;
      }
    • Exploit Scenario

      N/A

    • Recommendations

      Use descriptive error message.

      "LoanKernel: orderAddresses[CREDITOR] is zero address."

      Or provide an error message reference table, so one can find the detailed explanation with the error message code.

  1. Lack of state assertion on critical functions
    SeverityInformational
    Sourceprotocols/note-sale/base/LongSaleInterest.sol#L20;
    StatusResolved in commit 9ed09bcf4eb60e975ec9cbc403f2fb9d5bab5a26;
    • Description

      Inside function LongSaleInterest.getPurchasePrice(), it subtract _durationLengthInSec from _termLengthInSeconds and the purchase price is base on this subtract operation. It may fail with subtraction underflow and leads to an incorrect price.

    • Exploit Scenario

      N/A

    • Recommendations

      We recommend adding a require function to require _termLengthInSeconds not smaller than _durationLengthInSec with an error message.

  1. Unused variables
    SeverityInformational
    Sourceprotocols/note-sale/crowdsale/Crowdsale.sol#L33; pool/SecuritizationPool.sol#L120; tokens/ERC721/invoice/AcceptedInvoiceToken.sol#L163;
    StatusResolved in commit f83a44d8fc6be599c9ee3f68877ad0f855d500ae and commit b20946fe0f24a8528e6574d456d5f5e7a0cc34e4;
    • Description
      • State variable unused:
        • Crowdsale.solL33 initialized.
      • Function parameter unused:
        • SecuritizationPool.sol L120 operator, from, data ;
        • AcceptedInvoiceToken.sol L163 timestamp.

      these unused parameters and state variables waste gas.

    • Exploit Scenario

      N/A

    • Recommendations
      1. Remove unused state variables.
      1. Remove unused parameters, or add placeholders to mute the warnings:
      function getExpectedRepaymentValues(uint256 tokenId, uint256 timestamp) public view returns (uint256, uint256) {
      	timestamp; // placeholder
      	return (entries[bytes32(tokenId)].fiatAmount - entries[bytes32(tokenId)].paidAmount, 0);
      }

Access Control Analysis

  • SecuritizationPool.sol
    • owner
      • setPot()
      • setupRiskScores()
      • exportAssets()
      • withdrawAssets()
      • withdrawERC20Assets()
      • claimCashRemain()
      • startCycle()
    • ORIGINATOR_ROLE
      • collectERC20Assets()
    • SecuritizationManager(contract)
      • injectTGEAddress()
    • DistributionOperator(contract)
      • increaseLockedDistributeBalance()
      • decreaseLockedDistributeBalance()
      • increasePaidInterestAmountSOT()
      • increasePaidPrincipalAmountSOT()
    • tge
      • setInterestRateForSOT()
    • DistributionTranch (contract)
      • redeem()
  • SecuritizationManager.sol
    • manager (require caller to be pool owner)
      • initialTGEForSOT()
      • initialTGEForJOT()
    • admin
      • pausePool()
      • unpausePool()
      • pauseAllPools()
      • unpauseAllPools()
  • AcceptedInvoiceToken.sol
    • invoice creator
      • createBatch()

Also see the table in Off-Chain OpSec.

Off-Chain OpSec

Untangled Protocol creates a platform that connects real-world assets to the blockchain. Blockchains cannot inherently interact with data existing outside their native blockchain environment. To bridge the real-world assets on-chain, Untangled Protocol is heavily dependent on operations of off-chain components. In order to ensure the security and operations of the protocol, the following is a series of requirements and assumptions that off-chain components have to meet:

  • The real-world assets are represented by NFTs in Untangled Protocol. The real-world assets purchased by the Asset Pool are validated by validators and validators are auditors in the real world, providing the service for a fee.
  • The Asset Pool is set up by a trusted third party who represents the interests of investors. They are either independent directors or trustees and are reputable companies.
  • Pool owners are bounded by underlying contracts(off-chain) to represent the interest of investors
  • Originators are trusted third parties who are voted by Governance to sell assets to the pool. They are bound by off-chain legal agreements (asset purchase agreements) with the pool owner.

The following is a table that classifies the on-chain and off-chain data operations:

Key processes/functionalitiesOff chain dataWho can update
Open new account
- personalKYC infoPersonal User
- businessKYB infoBusiness User
Create new pool
- the minimum first lossOn-chainSet up by Pool Owner
- asset typeOn-chain
- currencyOn-chain
Review pool
- risk scoreOn-chainSet up by Pool Owner
Generate and sign documents
- receivable purchase agreementOff-chainGenerated by Pool Owner according to the relevant template
- SOT subscription agreementOff-chain
- JOT subscription agreementOff-chain
Issue note
-SOT target amountOn-chainSet up, updated by Pool Owner
- JOT target amountOn-chain
- Ensure JOT/SOT satisfies first lossOn-chain
- Set auction parametersOn-chain
Subscribe JOT
- View priceN/AN/A
- input amountOn-chainUser
- sign contractOff-chainUser
- link wallet and approve spendOn-chainUser
SOT sale starts at later of start auction or JOT sale completed
- view interestN/AN/A
- input amountOn-chainUser
- sign contractOff-chainUser
- link wallet and approve spendOn-chainUser
Pool view
- Subscribed SOT/JOTN/AN/A
- Pool reserveOn-chain linked walletPool Owner
- Price of SOTOn-chain calcA pool owner can update yield to maturity which affects the price
- Price of JOTOn-chain calc
- Performance chartsN/AN/A
Pool update
Target JOT amountOff-chain to on-chainPool Owner
Target SOT amountOff-chain to on-chain
Yield to maturityOff-chain to on-chain
Upload assets
- map fields (compulsory: borrowerID, Current balance, Original balance, Current interest, Final maturity date, Origination date, Loan denomination currency,)NFT metadata includes asset type (loan, invoices), amount, due date, yield, and owner - all other data are off-chainOff-chain data is on the database with Azure
- Approver/no approverApprover = Validator N/A
Asset repayment
- originator uploads repaymentsOff-chain to on-chainOriginators through API
- currency amount transferred from originator walletOn-chainOriginator
Redemption
- SOT holder make a redemption requestOn-chain
- Pool payout of from reserve according to the calculationOn-chain
- JOT redemptionOn-chain
Investor view
- pool investment opportunity (different tabs)N/AN/A
- profile (need to add wallet address)N/AUser
- walletNon-custodialUser
- document signing /repositoryOff-chainN/A
- PortfolioN/AN/A
- Currency only USDC, cUSD, DAI to start

Appendix I: Severity Categories

SeverityDescription
HighIssues that are highly exploitable security vulnerabilities. It may cause direct loss of funds / permanent freezing of funds. All high severity issues should be resolved.
MediumIssues that are only exploitable under some conditions or with some privileged access to the system. Users’ yields/rewards/information is at risk. All medium severity issues should be resolved unless there is a clear reason not to.
LowIssues that are low risk. Not fixing those issues will not result in the failure of the system. A fix on low severity issues is recommended but subject to the clients’ decisions.
InformationalIssues that pose no risk to the system and are related to the security best practices. Not fixing those issues will not result in the failure of the system. A fix on informational issues or adoption of those security best practices-related suggestions is recommended but subject to clients’ decision.

Appendix II: Status Categories

StatusDescription
UnresolvedThe issue is not acknowledged and not resolved.
Partially ResolvedThe issue has been partially resolved
AcknowledgedThe Finding / Suggestion is acknowledged but not fixed / not implemented.
ResolvedThe issue has been sufficiently resolved

Disclaimer

Verilog Solutions receives compensation from one or more clients for performing the smart contract and auditing analysis contained in these reports. The report created is solely for Clients and published with their consent. As such, the scope of our audit is limited to a review of code, and only the code we note as being within the scope of our audit detailed in this report. It is important to note that the Solidity code itself presents unique and unquantifiable risks since the Solidity language itself remains under current development and is subject to unknown risks and flaws. Our sole goal is to help reduce the attack vectors and the high level of variance associated with utilizing new and consistently changing technologies. Thus, Verilog Solutions in no way claims any guarantee of security or functionality of the technology we agree to analyze.

In addition, Verilog Solutions reports do not provide any indication of the technologies proprietors, business, business model, or legal compliance. As such, reports do not provide investment advice and should not be used to make decisions about investment or involvement with any particular project. Verilog Solutions has the right to distribute the Report through other means, including via Verilog Solutions publications and other distributions. Verilog Solutions makes the reports available to parties other than the Clients (i.e., “third parties”) – on its website in hopes that it can help the blockchain ecosystem develop technical best practices in this rapidly evolving area of innovation.