Prepared by:
HALBORN
Last Updated 04/25/2024
Date of Engagement by: July 25th, 2022 - August 12th, 2022
100% of all REPORTED Findings have been addressed
All findings
32
Critical
7
High
8
Medium
6
Low
5
Informational
6
Critical
7
High
8
Medium
6
Low
5
Informational
6
Impact x Likelihood
HAL-09
HAL-01
HAL-02
HAL-03
HAL-04
HAL-05
HAL-06
HAL-07
HAL-22
HAL-17
HAL-20
HAL-11
HAL-13
HAL-14
HAL-15
HAL-12
HAL-24
HAL-25
HAL-16
HAL-21
HAL-19
HAL-08
HAL-10
HAL-26
HAL-23
HAL-18
HAL-27
HAL-28
HAL-29
HAL-30
HAL-31
HAL-32
Security analysis | Risk level | Remediation Date |
---|---|---|
LENDER LIQUIDITY LOCKOUT POSSIBLE VIA DEPOSITANDCLOSE FUNCTION | Critical | Solved - 07/26/2022 |
DEBT PAY OFF IMPOSSIBLE DUE TO INTEGER UNDERFLOW | Critical | Solved - 08/25/2022 |
LENDER CAN WITHDRAW INTEREST MULTIPLE TIMES | Critical | Solved - 08/25/2022 |
ORACLE PRICE AFFECTS THE POSSIBILITY OF DEBT REPAYMENT | Critical | Solved - 08/25/2022 |
WITHDRAWING ALL LIQUIDITY BEFORE BORROWING CAN DEADLOCK CONTRACT | Critical | Solved - 08/25/2022 |
SWEEP FUNCTION DOES NOT WORK FOR ARBITER | Critical | Solved - 08/25/2022 |
COLLATERAL TOKENS LOCKOUT IN ESCROW | Critical | Solved - 08/25/2022 |
GETOUTSTANDINGDEBT FUNCTION RETURNS UNDERSTATED VALUE | High | Solved - 08/25/2022 |
BORROWING FROM NON-FIRST POSITION CAN DEADLOCK CONTRACT | High | Solved - 08/25/2022 |
UPDATEOWNERSPLIT FUNCTION CAN BE ABUSED BY LENDER OR BORROWER | High | Risk Accepted |
UNUSED REVENUE TOKENS LOCKOUT WHILE LOAN IS ACTIVE | High | Solved - 08/25/2022 |
PAYING OFF DEBT WITH SPIGOT EARNING IN ETHER IS NOT POSSIBLE | High | Solved - 08/25/2022 |
DOUBLE UPDATE OF UNUSEDTOKENS COLLECTION POSSIBLE | High | Solved - 08/25/2022 |
UNEXPECTED LIQUIDATABLE STATUS IN NEW ESCROWEDLOAN | High | Risk Accepted |
CANNOT LIQUIDATE LIQUIDATABLE SECUREDLOAN DUE TO COLLATERAL RATIO CHECK | High | Solved - 08/25/2022 |
CREDIT CAN BE CLOSED WITHOUT PAYING INTEREST FROM UNUSED FUNDS | Medium | Solved - 08/26/2022 |
CLOSE FUNCTION CAN BE FRONT-RUN BY LENDER | Medium | Solved - 08/26/2022 |
UNUSED CREDIT TOKENS LOCKOUT UNTIL NEW REVENUE | Medium | Solved - 08/26/2022 |
BORROWER CAN CLAIM REVENUE WHILE LOAN IS LIQUIDATABLE | Medium | Risk Accepted |
MINIMUMCOLLATERALRATIO LACKS INPUT VALIDATION | Medium | Solved - 08/29/2022 |
REVENUE CONTRACT OWNERSHIP LOCKOUT POSSIBLE IN REMOVESPIGOT | Medium | Solved - 08/26/2022 |
MALICIOUS ARBITER CAN ALLOW OWNERSHIP TRANSFER FUNCTION TO OPERATOR | Low | Risk Accepted |
UPDATEWHITELISTFUNCTION EVENT IS ALWAYS EMITTED WITH TRUE VALUE | Low | Solved - 08/26/2022 |
BORROWER CAN MINIMIZE DRAWN INTEREST ACCRUING | Low | Risk Accepted |
REMOVESPIGOT DOES NOT CHECK CONTRACT'S BALANCE | Low | Solved - 08/26/2022 |
INCREASECREDIT FUNCTION LACKS CALL TO SORTINTOQ | Low | Risk Accepted |
GAS OVER-CONSUMPTION IN LOOPS | Informational | Solved - 09/02/2022 |
UNNEEDED INITIALIZATION OF UINT256 VARIABLES TO 0 | Informational | Solved - 09/02/2022 |
ASSERTIONS LACK MESSAGES | Informational | Solved - 08/26/2022 |
DEFAULTREVENUESPLIT LACKS INPUT VALIDATION | Informational | Solved - 08/26/2022 |
UNUSED CODE | Informational | Solved - 08/26/2022 |
LACK OF CHECK EFFECTS INTERACTIONS PATTERN OR REENTRENCY GUARD | Informational | Solved - 08/29/2022 |
// Critical
In the LineOfCredit
contract, the borrower has two possibilities to pay off the debt: by calling depositAndRepay()
and then close()
functions, or by calling a single depositAndClose()
function.LineOfCredit``depositAndRepay()``close()``depositAndClose()
The assessment revealed that the depositAndClose()
does not transfer funds back to the lender, yet it deletes the debt record (using the internal _close
function). As a result, the lender's liquidity is locked in the contract.depositAndClose()``_close
function depositAndClose()
whileBorrowing
onlyBorrower
override external
returns(bool)
{
bytes32 id = positionIds[0];
_accrueInterest(id);
uint256 totalOwed = debts[id].principal + debts[id].interestAccrued;
// borrower deposits remaining balance not already repaid and held in contract
bool success = IERC20(debts[id].token).transferFrom(
msg.sender,
address(this),
totalOwed
);
require(success, 'Loan: deposit failed');
// clear the debt
_repay(id, totalOwed);
require(_close(id));
return true;
}
function close(bytes32 positionId) override external returns(bool) {
DebtPosition memory debt = debts[positionId];
require(
msg.sender == debt.lender ||
msg.sender == borrower,
"Loan: msg.sender must be the lender or borrower"
);
// return the lender's deposit
if(debt.deposit > 0) {
require(IERC20(debt.token).transfer(debt.lender, debt.deposit + debt.interestRepaid));
}
require(_close(positionId));
return true;
}
function _close(bytes32 positionId) virtual internal returns(bool) {
require(
debts[positionId].principal + debts[positionId].interestAccrued == 0,
'Loan: close failed. debt owed'
);
delete debts[positionId]; // yay gas refunds!!!
// remove from active list
positionIds = LoanLib.removePosition(positionIds, positionId);
// brick loan contract if all positions closed
if(positionIds.length == 0) {
loanStatus = LoanLib.STATUS.REPAID;
}
emit CloseDebtPosition(positionId);
return true;
}
borrow
all deposit.depositAndClose
to pay the debt.close
function. Observe that it reverts with the error (Loan: msg.sender must be the lender or borrower)
.1. All necessary contracts are deployed and initialized: RevenueToken, SimpleOracle, LoanLib, LineOfCredit.borrow
all deposit.
borrow
4. As borrower call depositAndClose
to pay the debt.
depositAndClose
5. Observe that lender did not receive the liquidity.close
function. Observe that it reverts with the error (Loan: msg.sender must be the lender or borrower)
.close``(Loan: msg.sender must be the lender or borrower)
\
SOLVED: The Debt DAO team
solved this issue in commit
d739f19d646a2d192aae1e8f56f11e90bbc75dac: the transfer of lender's liquidity now happens on _close()
internal function which is called by the depositAndClose()
and close()
functions.SOLVED:Debt DAO team
d739f19d646a2d192aae1e8f56f11e90bbc75dac_close()``depositAndClose()``close()
\
// Critical
In the LineOfCredit
contract, the borrower has two possibilities to pay off the debt: by calling depositAndRepay()
and then close()
functions, or by calling a single depositAndClose()
function.LineOfCredit``depositAndRepay()``close()``depositAndClose()
The assessment revealed that both functions depositAndClose()
and depositAndRepay()
revert due to integer overflow. The error occurs due to principalUsd
parameter subtraction done in _repay
function. The principalUsd
parameter is supposed to have a non-zero value; however, due to condition check in _createCredit
where principal
parameter is always 0, the principalUsd
parameter is not updated.depositAndClose()``depositAndRepay()``principalUsd``_repay``principalUsd``_createCredit``principal``principalUsd
function addCredit(
uint128 drate,
uint128 frate,
uint256 amount,
address token,
address lender
)
external
virtual
override
whileActive
mutualConsent(lender, borrower)
returns (bytes32)
{
bool success = IERC20(token).transferFrom(
lender,
address(this),
amount
);
require(success, "Loan: no tokens to lend");
bytes32 id = _createCredit(lender, token, amount, 0);
require(interestRate.setRate(id, drate, frate));
return id;
}
function _createCredit(
address lender,
address token,
uint256 amount,
uint256 principal
) internal returns (bytes32 id) {
id = LoanLib.computePositionId(address(this), lender, token);
// MUST not double add position. otherwise we can not _close()
require(
credits[id].lender == address(0),
"Loan: position exists"
);
(bool passed, bytes memory result) = token.call(
abi.encodeWithSignature("decimals()")
);
uint8 decimals = !passed ? 18 : abi.decode(result, (uint8));
uint256 value = LoanLib.getValuation(oracle, token, amount, decimals);
require(value > 0 , "Loan: token cannot be valued");
credits[id] = Credit({
lender: lender,
token: token,
decimals: decimals,
deposit: amount,
principal: principal,
interestAccrued: 0,
interestRepaid: 0
});
ids.push(id); // add lender to end of repayment queue
emit AddCredit(lender, token, amount, 0);
if(principal > 0) {
principalUsd += value;
emit Borrow(id, principal, value);
}
return id;
}
function _repay(bytes32 id, uint256 amount)
internal
returns (bool)
{
Credit memory credit = credits[id];
int price = oracle.getLatestAnswer(credit.token);
if (amount <= credit.interestAccrued) {
credit.interestAccrued -= amount;
uint256 val = LoanLib.calculateValue(price, amount, credit.decimals);
interestUsd -= val;
credit.interestRepaid += amount;
emit RepayInterest(id, amount, val);
} else {
uint256 principalPayment = amount - credit.interestAccrued;
uint256 iVal = LoanLib.calculateValue(price, credit.interestAccrued, credit.decimals);
uint256 pVal = LoanLib.calculateValue(price, principalPayment, credit.decimals);
emit RepayInterest(id, credit.interestAccrued, iVal);
emit RepayPrincipal(id, principalPayment, pVal);
// update global credit denominated in usd
interestUsd -= iVal;
principalUsd -= pVal;
// update individual credit position denominated in token
credit.principal -= principalPayment;
credit.interestRepaid += credit.interestAccrued;
credit.interestAccrued = 0;
// if credit fully repaid then remove lender from repayment queue
if (credit.principal == 0) ids = LoanLib.stepQ(ids);
}
credits[id] = credit;
return true;
}
borrow()
all deposits.depositAndClose
to pay the debt.depositAndRepay
to pay the debt.borrow()
all deposits.
borrow()
4. As the borrower, attempt to call depositAndClose
to pay the debt.
depositAndClose
5. Observe that transaction reverts due to integer overflow.depositAndRepay
to pay the debt.
depositAndRepay
5. Observe that transaction reverts due to integer overflow.\
SOLVED: The Debt DAO team
solved this issue in commit
51dab65755c9978333b147f99db007a93bd0f81c: the principalUsd
parameter and related calculations are now removed. SOLVED:Debt DAO team
51dab65755c9978333b147f99db007a93bd0f81cprincipalUsd
\
// Critical
In the LineOfCredit
contract, a lender has the possibility to withdraw accrued interest via withdrawInterest()
function. The function does not record the fact of withdrawal; thus, the function can be called multiple times until the contract has a positive token balance. LineOfCredit``withdrawInterest()
As a result, in the case of multiple lenders recorded in the contract, one lender can extract liquidity from other lenders. Alternatively, a lender can pull unborrowed deposits and force borrowers to pay off higher debt than expected, or force default.
function withdrawInterest(bytes32 id)
external
override
returns (uint256)
{
require(
msg.sender == credits[id].lender,
"Loan: only lender can withdraw"
);
_accrueInterest(id);
uint256 amount = credits[id].interestAccrued;
bool success = IERC20(credits[id].token).transfer(
credits[id].lender,
amount
);
require(success, "Loan: withdraw failed");
emit WithdrawProfit(id, amount);
return amount;
}
Scenario 1 - steal other lenders' liquidity
Scenario 1 - steal other lenders' liquidity
1. All necessary contracts are deployed and initialized: RevenueToken, SimpleOracle, LoanLib, LineOfCredit.
2. As borrower and lender1, add credit position for 10_000_000_000_000_000 tokens and drawn rate set to 3000. This action registers the first credit position.
3. As the borrower and the lender2, add credit position for 10_000_000_000_000_000 tokens and drawn rate set to 3000. This action registers the second credit position.
4. As borrower, borrow
10_000_000_000_000_000 tokens from the first position.
5. Forward blockchain time for 20 days.
6. As the lender1, call withdrawInterest
for the first credit position ten times.
7. As the borrower, call depositAndClose
to pay off the debt and close the first position.
8. As the borrower, attempt to borrow
10_000_000_000_000_000 tokens from the second position.
9. Observe that transaction reverts due to ERC20: transfer amount exceeds balance
error. Note that LineOfCredit
balance is below 10_000_000_000_000_000.1. All necessary contracts are deployed and initialized: RevenueToken, SimpleOracle, LoanLib, LineOfCredit.
2. As borrower and lender1, add credit position for 10_000_000_000_000_000 tokens and drawn rate set to 3000. This action registers the first credit position.
3. As the borrower and the lender2, add credit position for 10_000_000_000_000_000 tokens and drawn rate set to 3000. This action registers the second credit position.
4. As borrower, borrow
10_000_000_000_000_000 tokens from the first position.
borrow
5. Forward blockchain time for 20 days.
6. As the lender1, call withdrawInterest
for the first credit position ten times.
withdrawInterest
7. As the borrower, call depositAndClose
to pay off the debt and close the first position.
depositAndClose
8. As the borrower, attempt to borrow
10_000_000_000_000_000 tokens from the second position.
borrow
9. Observe that transaction reverts due to ERC20: transfer amount exceeds balance
error. Note that LineOfCredit
balance is below 10_000_000_000_000_000.ERC20: transfer amount exceeds balance``LineOfCredit
Scenario 2 - steal borrower liquidity
Scenario 2 - steal borrower liquidity
1. All necessary contracts are deployed and initialized: RevenueToken, SimpleOracle, LoanLib, LineOfCredit.
2. As borrower and lender1, add credit position for 10_000_000_000_000_000 tokens and drawn rate set to 3000.
4. As borrower, borrow
5_000_000_000_000_000 tokens.
5. Forward blockchain time for 20 days.
6. As the lender1, call withdrawInterest
ten times.
7. As the borrower, attempt to borrow
5_000_000_000_000_000 residual tokens.
8. Observe that transaction reverts due to ERC20: transfer amount exceeds balance
error. Note that LineOfCredit
balance is below 5_000_000_000_000_000.
9. As the borrower, attempt to call depositAndClose
to pay off the debt.
10. Observe that transaction reverts due to ERC20: transfer amount exceeds balance
error.1. All necessary contracts are deployed and initialized: RevenueToken, SimpleOracle, LoanLib, LineOfCredit.
2. As borrower and lender1, add credit position for 10_000_000_000_000_000 tokens and drawn rate set to 3000.
4. As borrower, borrow
5_000_000_000_000_000 tokens.
borrow
5. Forward blockchain time for 20 days.
6. As the lender1, call withdrawInterest
ten times.
withdrawInterest
7. As the borrower, attempt to borrow
5_000_000_000_000_000 residual tokens.
borrow
8. Observe that transaction reverts due to ERC20: transfer amount exceeds balance
error. Note that LineOfCredit
balance is below 5_000_000_000_000_000.
ERC20: transfer amount exceeds balance``LineOfCredit
9. As the borrower, attempt to call depositAndClose
to pay off the debt.
depositAndClose
10. Observe that transaction reverts due to ERC20: transfer amount exceeds balance
error.ERC20: transfer amount exceeds balance
\
SOLVED: The Debt DAO team
solved this issue in commit
a18167d41eb4e1ccb70bbeceeef0aff1c93b05f9: the withdrawInterest()
function is now removed from contract.SOLVED:Debt DAO team
a18167d41eb4e1ccb70bbeceeef0aff1c93b05f9withdrawInterest()
\
// Critical
The LineOfCredit
contract tracks unpaid interest valuated in USD by interestUsd
parameter. This parameter is updated with addition in _accrueInterest()
internal function and subtraction in _repay()
internal function. The _accrueInterest()
is used by accrueInterest()
, setRates()
, increaseCredit()
, borrow()
, withdraw()
, and withdrawInterest()
functions among the others. The _repay()
is used by depositAndRepay()
and depositAndClose()
functions. LineOfCredit``interestUsd``_accrueInterest()``_repay()``_accrueInterest()``accrueInterest()``setRates()``increaseCredit()``borrow()``withdraw()``withdrawInterest()``_repay()``depositAndRepay()``depositAndClose()
function _accrueInterest(bytes32 id)
internal
returns (uint256 accruedToken, uint256 accruedValue)
{
Credit memory credit = credits[id];
// get token demoninated interest accrued
accruedToken = interestRate.accrueInterest(
id,
credit.principal,
credit.deposit
);
// update credits balance
credit.interestAccrued += accruedToken;
// get USD value of interest accrued
accruedValue = LoanLib.getValuation(
oracle,
credit.token,
accruedToken,
credit.decimals
);
interestUsd += accruedValue;
emit InterestAccrued(id, accruedToken, accruedValue);
credits[id] = credit; // save updates to intterestAccrued
return (accruedToken, accruedValue);
}
function _repay(bytes32 id, uint256 amount)
internal
returns (bool)
{
Credit memory credit = credits[id];
int price = oracle.getLatestAnswer(credit.token);
if (amount <= credit.interestAccrued) {
credit.interestAccrued -= amount;
uint256 val = LoanLib.calculateValue(price, amount, credit.decimals);
interestUsd -= val;
credit.interestRepaid += amount;
emit RepayInterest(id, amount, val);
} else {
uint256 principalPayment = amount - credit.interestAccrued;
uint256 iVal = LoanLib.calculateValue(price, credit.interestAccrued, credit.decimals);
uint256 pVal = LoanLib.calculateValue(price, principalPayment, credit.decimals);
emit RepayInterest(id, credit.interestAccrued, iVal);
emit RepayPrincipal(id, principalPayment, pVal);
// update global credit denominated in usd
interestUsd -= iVal;
principalUsd -= pVal;
// update individual credit position denominated in token
credit.principal -= principalPayment;
credit.interestRepaid += credit.interestAccrued;
credit.interestAccrued = 0;
// if credit fully repaid then remove lender from repayment queue
if (credit.principal == 0) ids = LoanLib.stepQ(ids);
}
credits[id] = credit;
return true;
}
\color{black}
\color{white}
The value of interestUsd
parameter is strongly affected by the price returned by Oracle
. Thus, if the Oracle
returns a higher value than previously, an integer underflow occurs in _repay()
function, making debt repayment impossible. To exploit this vulnerability, _accrueInterest()
must be called prior to _repay()
to update interestUsd
parameter.interestUsd``Oracle``Oracle``_repay()``_accrueInterest()``_repay()``interestUsd
/**
* @notice - Gets total valuation for amount of tokens using given oracle.
* @dev - Assumes oracles all return answers in USD with 1e8 decimals
- Does not check if price < 0. HAndled in Oracle or Loan
* @param oracle - oracle contract specified by loan getting valuation
* @param token - token to value on oracle
* @param amount - token amount
* @param decimals - token decimals
* @return - total value in usd of all tokens
*/
function getValuation(
IOracle oracle,
address token,
uint256 amount,
uint8 decimals
)
external
returns(uint256)
{
return _calculateValue(oracle.getLatestAnswer(token), amount, decimals);
}
The codebase uses SimpleOracle
mock for testing. Based on this contract, the ChangingOracle
was prepared that mimics the price increase after ten days.SimpleOracle``ChangingOracle
pragma solidity 0.8.9;
import { IOracle } from "../interfaces/IOracle.sol";
import { LoanLib } from "../utils/LoanLib.sol";
contract ChangingOracle is IOracle {
mapping(address => int) prices;
uint256 public immutable creationTime;
constructor(address _supportedToken1, address _supportedToken2) {
prices[_supportedToken1] = 1000 * 1e8; // 1000 USD
prices[_supportedToken2] = 2000 * 1e8; // 2000 USD
creationTime = block.timestamp;
}
function init() external returns(bool) {
return true;
}
function changePrice(address token, int newPrice) external {
prices[token] = newPrice;
}
function getLatestAnswer(address token) external returns(int256) {
// mimic eip4626
// (bool success, bytes memory result) = token.call(abi.encodeWithSignature("asset()"));
// if(success && result.length > 0) {
// // get the underlying token value (if ERC4626)
// // NB: Share token to underlying ratio might not be 1:1
// token = abi.decode(result, (address));
// }
require(prices[token] != 0, "SimpleOracle: unsupported token");
//simulate price change
uint256 difference = block.timestamp - creationTime;
if (difference > 900000) //900000 = 10 days and 10 hours
return prices[token] * 10001 / 10000;
return prices[token];
}
function healthcheck() external returns (LoanLib.STATUS status) {
return LoanLib.STATUS.ACTIVE;
}
function loan() external returns (address) {
return address(0);
}
}
\color{black}
\color{white}
1. All necessary contracts are deployed and initialized: RevenueToken, ChangingOracle
, LoanLib, LineOfCredit.
2. As the borrower and lender1, add credit position for 1_000_000_000_000_000 tokens.
3. As the borrower, borrow
1_000_000_000_000_000 tokens.
4. Forward blockchain time for 10 days.
5. Call accrueInterest
function. Note that the interestUsd
parameter value is updated.
6. Forward blockchain time for 1 day. Note that after 11 days, the ChangingOracle
will return higher results.
7. As the borrower, attempt to call depositAndClose
.
8. Observe that transaction reverts due to integer overflow.1. All necessary contracts are deployed and initialized: RevenueToken, ChangingOracle
, LoanLib, LineOfCredit.
ChangingOracle
2. As the borrower and lender1, add credit position for 1_000_000_000_000_000 tokens.
3. As the borrower, borrow
1_000_000_000_000_000 tokens.
borrow
4. Forward blockchain time for 10 days.
5. Call accrueInterest
function. Note that the interestUsd
parameter value is updated.
accrueInterest``interestUsd
6. Forward blockchain time for 1 day. Note that after 11 days, the ChangingOracle
will return higher results.
ChangingOracle
7. As the borrower, attempt to call depositAndClose
.
depositAndClose
8. Observe that transaction reverts due to integer overflow.
\
SOLVED: The Debt DAO team
solved this issue in commit
51dab65755c9978333b147f99db007a93bd0f81c,cbb2f0f2b68b966e95d2d64a2686de33f4c0496b: the interestUsd
parameter and related calculations are now removed. SOLVED:Debt DAO team
51dab65755c9978333b147f99db007a93bd0f81ccbb2f0f2b68b966e95d2d64a2686de33f4c0496binterestUsd
\
// Critical
In the LineOfCredit
contract, the lender has the possibility to withdraw all unborrowed deposit previously provided for loan through withdraw()
function.LineOfCredit``withdraw()
The assessment revealed that withdrawal of all deposits, before the borrower borrows any amount, can deadlock the contract. The withdraw()
function calls _accrueInterest()
functions, so a small amount of facility interest is accrued. Eventually, the borrower can't pay off the debt, close the credit, or release the spigots.
The whileBorrowing()
modifier checks if any principal is borrowed; however, it does not check if any interest is accrued. The whileBorrowing()
modifier is used both in LineOfCredit
and SpigotedLoan
contracts in depositAndClose()
, depositAndRepay()
, claimAndRepay()
and claimAndTrade()
functions.withdraw()``_accrueInterest()``whileBorrowing()``whileBorrowing()``LineOfCredit``SpigotedLoan``depositAndClose()``depositAndRepay()``claimAndRepay()``claimAndTrade()
modifier whileBorrowing() {
require(ids.length > 0 && credits[ids[0]].principal > 0);
_;
}
withdraw()
all deposits.depositAndClose
. Observe that the transaction reverted.close
credit. Observe that the transaction reverted with Loan: close failed. credit owed
error.borrow
deposit. Observe that the transaction reverted with Loan: no liquidity
error.1. All necessary contracts are deployed and initialized: RevenueToken, SimpleOracle, LoanLib, LineOfCredit.withdraw()
all deposits.
withdraw()
4. As the borrower, attempt to depositAndClose
. Observe that the transaction reverted.
depositAndClose
5. As the borrower, attempt to close
credit. Observe that the transaction reverted with Loan: close failed. credit owed
error.
close``Loan: close failed. credit owed
6. As the borrower, attempt to borrow
deposit. Observe that the transaction reverted with Loan: no liquidity
error.borrow``Loan: no liquidity
\
SOLVED: The Debt DAO team
solved this issue in commit
b30347d17a90980aa7ca7c0ffb25067f87039c6d: the _close()
internal function now checks only that the principal
is not zero before closing. It does not check the interestAccrued
parameter.SOLVED:Debt DAO team
b30347d17a90980aa7ca7c0ffb25067f87039c6d_close()``principal``interestAccrued
\
// Critical
The SpigotedLoan
contract implements a fallback mechanism to withdraw all unused funds from spigots in case of the borrower default. The sweep()
function can be called to send all unused funds (based on unusedTokens
collection) to the arbiter when the loan has defaulted and the status is set to INSOLVENT
. However, the INSOLVENT
status is never assigned to the loan in the solution, whereas the loan can have LIQUIDATABLE
status assigned e.g., in healthcheck()
function when the debt deadline has passed.SpigotedLoan``sweep()``unusedTokens``INSOLVENT``INSOLVENT``LIQUIDATABLE``healthcheck()
* @notice - sends unused tokens to borrower if repaid or arbiter if liquidatable
- doesnt send tokens out if loan is unpaid but healthy
* @dev - callable by anyone
* @param token - token to take out
*/
function sweep(address token) external returns (uint256) {
if (loanStatus == LoanLib.STATUS.REPAID) {
return _sweep(borrower, token);
}
if (loanStatus == LoanLib.STATUS.INSOLVENT) {
return _sweep(arbiter, token);
}
return 0;
}
function _sweep(address to, address token) internal returns (uint256 x) {
x = unusedTokens[token];
if (token == address(0)) {
payable(to).transfer(x);
} else {
require(IERC20(token).transfer(to, x));
}
delete unusedTokens[token];
}
\color{black}
\color{white}
As a result, all unused revenue and credit tokens stored in SpigotedLoan
(unusedTokens
collection) are locked in the contract. The credit token can be transferred to the lender using claimAndRepay()
function, unless the spigot is still owned by the SpigotedLoan
contract, and it is providing new revenue. On the other hand, the revenue token is locked permanently. SpigotedLoan``unusedTokens``claimAndRepay()``SpigotedLoan
ttl
parameter to 1 day.borrow()
half of the deposit - 5_000_000_000_000_000.addSpigot()
function and RevenueContract
as input. RevenueContract
contract to the SpigotController.RevenueContract
contract to simulate token gain.borrow()
the remaining deposit. Observe that the transaction reverted with Loan: can't borrow
error.1. All necessary contracts are deployed and initialized: CreditToken, RevenueToken, SimpleOracle, LoanLib, SpigotedLoan, SimpleRevenueContract. Set the ttl
parameter to 1 day.
ttl
2. As the borrower and lender1, add credit position for 10_000_000_000_000_000 tokens.borrow()
half of the deposit - 5_000_000_000_000_000.
borrow()
2. As th borrower and arbiter, add new spigot with addSpigot()
function and RevenueContract
as input.
addSpigot()``RevenueContract
3. As the borrower, transfer the ownership of RevenueContract
contract to the SpigotController.
RevenueContract
4. Mint 500_000_000_000_000 revenue tokens to the RevenueContract
contract to simulate token gain.
RevenueContract
5. Forward blockchain time for 1 day and 1 second.borrow()
the remaining deposit. Observe that the transaction reverted with Loan: can't borrow
error.borrow()``Loan: can't borrow
healthcheck()
function. Note that the loan's status changed from ACTIVE
to LIQUIDATABLE
.updateOwnerSplit
so 100% of revenue will go to the SpigotController contract.7. As the arbiter, call healthcheck()
function. Note that the loan's status changed from ACTIVE
to LIQUIDATABLE
.
healthcheck()``ACTIVE``LIQUIDATABLE
8. As the arbiter, call updateOwnerSplit
so 100% of revenue will go to the SpigotController contract.updateOwnerSplit
claimRevenue()
in SpigotController contract to claim revenue tokens. Note that 100% of tokens (500_000_000_000_000) are transferred to the SpigotController.9. As the arbiter, call claimRevenue()
in SpigotController contract to claim revenue tokens. Note that 100% of tokens (500_000_000_000_000) are transferred to the SpigotController.claimRevenue()
claimAndRepay()
in SpigotedLoan contract to trade escrowed revenue tokens and pay off part of the debt. As an input, trade exchange data provide 250_000_000_000_000 revenue tokens that should be exchanged for credit tokens.unusedTokens
collection.10. As the arbiter, call claimAndRepay()
in SpigotedLoan contract to trade escrowed revenue tokens and pay off part of the debt. As an input, trade exchange data provide 250_000_000_000_000 revenue tokens that should be exchanged for credit tokens.
claimAndRepay()
11. Observe the SpigotedLoan balances. Note that the contract has 750,000,000,000,000 credit tokens and 250,000,000,000,000 revenue tokens. Also, 250,000,000,000,000 credit tokens and 250,000,000,000,000 revenue tokens are stored in unusedTokens
collection.unusedTokens
sweep()
function with revenue token address as an input. Note that the function returns a 0 value.sweep()
function with credit token address as an input. Note that the function returns a 0 value.sweep()
function with revenue token address as an input. Note that the function returns a 0 value.
sweep()
13. As arbiter, call sweep()
function with credit token address as an input. Note that the function returns a 0 value.
sweep()
14. Observe that SpigotedLoan balances remain unchanged.
\
SOLVED: The Debt DAO team
solved this issue in commit
2f7f0b44a2d257c92d7626f14f579876c7d00fee: the sweep()
function now checks loan status against LIQUIDATABLE
value.SOLVED:Debt DAO team
2f7f0b44a2d257c92d7626f14f579876c7d00feesweep()``LIQUIDATABLE
\
// Critical
In the Escrow
contract, the releaseCollateral()
function allows the borrower to withdraw collateral with the assumption that the remaining collateral is still above the minimum threshold. When the debt is paid off, the borrower should be allowed to withdraw all remaining collateral. However, the assessment revealed that withdraw of the remaining collateral is not possible. Escrow``releaseCollateral()
function releaseCollateral(
uint256 amount,
address token,
address to
) external returns (uint256) {
require(amount > 0, "Escrow: amount is 0");
require(msg.sender == borrower, "Escrow: only borrower can call");
require(
deposited[token].amount >= amount,
"Escrow: insufficient balance"
);
deposited[token].amount -= amount;
require(IERC20(token).transfer(to, amount));
uint256 cratio = _getLatestCollateralRatio();
require(
cratio >= minimumCollateralRatio,
"Escrow: cannot release collateral if cratio becomes lower than the minimum"
);
emit RemoveCollateral(token, amount);
return cratio;
}
When the last part of the collateral is released, the releaseCollateral()
function updates the deposited[token].amount
with 0 value. Then the _getLatestCollateralRatio()
returns 0 as collateralValue
is also 0. Therefore, it is not possible to pass the assertion cratio >= minimumCollateralRatio
as it is always false
.releaseCollateral()``deposited[token].amount``_getLatestCollateralRatio()``collateralValue``cratio >= minimumCollateralRatio``false
/**
* @notice updates the cratio according to the collateral value vs loan value
* @dev calls accrue interest on the loan contract to update the latest interest payable
* @return the updated collateral ratio in 18 decimals
*/
function _getLatestCollateralRatio() internal returns (uint256) {
ILoan(loan).accrueInterest();
uint256 debtValue = ILoan(loan).getOutstandingDebt();
uint256 collateralValue = _getCollateralValue();
if (collateralValue == 0) return 0;
if (debtValue == 0) return MAX_INT;
return _percent(collateralValue, debtValue, 18);
}
RevenueToken
token as collateral.RevenueToken
tokens as collateral.borrow
all deposits.releaseCollateral()
to withdraw all remaining collateral. Observe that the transaction reverts with Escrow: cannot release collateral if cratio becomes lower than the minimum
error. Note that 200_000_000_000_000 of RevenueToken
tokens are locked in the Escrow
contract.1. All necessary contracts are deployed and initialized: CreditToken, RevenueToken, SimpleOracle, LoanLib, EscrowedLoan.RevenueToken
token as collateral.
RevenueToken
3. As the borrower, add 200_000_000_000_000 of RevenueToken
tokens as collateral.
RevenueToken
2. As borrower and lender, add debt position.borrow
all deposits.
borrow
4. As the borrower, attempt to call releaseCollateral()
to withdraw all remaining collateral. Observe that the transaction reverts with Escrow: cannot release collateral if cratio becomes lower than the minimum
error. Note that 200_000_000_000_000 of RevenueToken
tokens are locked in the Escrow
contract.releaseCollateral()``Escrow: cannot release collateral if cratio becomes lower than the minimum``RevenueToken``Escrow
\
SOLVED: The Debt DAO team
solved this issue in commit
e97e8be0c7ecf8710a996b8f202bbd3a6bee0e2c: the releaseCollateral()
function now checks the loan status against REPAID
value.SOLVED:Debt DAO team
e97e8be0c7ecf8710a996b8f202bbd3a6bee0e2creleaseCollateral()``REPAID
\
// High
In the LineOfCredit
contract, the getOutstandingDebt()
function allows users to get information about total outstanding debt valuated in USD. The presented amount is a sum of principal and interest. The assessment revealed that the getOutstandingDebt()
function does not consider the accrued interest from the last evaluation period. Thus, it presents misleading, understated value to the users as actual debt is higher. LineOfCredit``getOutstandingDebt()``getOutstandingDebt()
/**
* @notice - Returns total credit obligation of borrower.
Aggregated across all lenders.
Denominated in USD 1e8.
* @dev - callable by anyone
*/
function getOutstandingDebt() external override returns (uint256) {
(uint256 p, uint256 i) = _updateOutstandingCredit();
return p + i;
}
function _updateOutstandingCredit()
internal
returns (uint256 principal, uint256 interest)
{
uint256 len = ids.length;
if (len == 0) return (0, 0);
Credit memory credit;
for (uint256 i = 0; i < len; i++) {
credit = credits[ids[i]];
int256 price = oracle.getLatestAnswer(credit.token);
principal += LoanLib.calculateValue(
price,
credit.principal,
credit.decimals
);
interest += LoanLib.calculateValue(
price,
credit.interestAccrued,
credit.decimals
);
}
principalUsd = principal;
interestUsd = interest;
}
borrow
500_000_000_000_000 tokens.getOutstandingDebt()
function. Observe that returned value of 100000000
does not include accrued interest.accrueInterest()
function. Observe that returned value of 27652
.borrow
500_000_000_000_000 tokens.getOutstandingDebt()
function. Observe that returned value of 200027652
. Note that this value includes the accrued interest from step 6. accrueInterest()
function. Observe that returned value of 54757
. Note that this value includes the accrued interest only from the last 10 days and was not included in step 9.1. All necessary contracts are deployed and initialized: RevenueToken, SimpleOracle, LoanLib, LineOfCredit.borrow
500_000_000_000_000 tokens.
borrow
4. Forward blockchain time for 10 days.getOutstandingDebt()
function. Observe that returned value of 100000000
does not include accrued interest.
getOutstandingDebt()``100000000
6. Call accrueInterest()
function. Observe that returned value of 27652
.
accrueInterest()``27652
7. As borrower, borrow
500_000_000_000_000 tokens.
borrow
8. Forward blockchain time for 10 days.getOutstandingDebt()
function. Observe that returned value of 200027652
. Note that this value includes the accrued interest from step 6.
getOutstandingDebt()``200027652
10. Call the accrueInterest()
function. Observe that returned value of 54757
. Note that this value includes the accrued interest only from the last 10 days and was not included in step 9.accrueInterest()``54757
\
SOLVED: The Debt DAO team
solved this issue in commit
c571e48d52f886b2d4c9f930a6bb27cf331501ae: the getOutstandingDebt()
function is now removed. The updateOutstandingDebt()
function is now added and includes both updated principal and accrued interest.SOLVED:Debt DAO team
c571e48d52f886b2d4c9f930a6bb27cf331501aegetOutstandingDebt()``updateOutstandingDebt()
\
// High
In the LineOfCredit
contract, a borrower can add multiple credits with various lenders. The borrower can borrow any amount from any credit position using the borrow(bytes32 id, uint256 amount)
function. However, the borrower can only repay first credit position using depositAndRepay()
or depositAndClose()
functions.LineOfCredit``borrow(bytes32 id, uint256 amount)``depositAndRepay()``depositAndClose()
/**
* @notice - Transfers enough tokens to repay entire credit position from `borrower` to Loan contract.
* @dev - callable by borrower
*/
function depositAndClose()
external
override
whileBorrowing
onlyBorrower
returns (bool)
{
bytes32 id = ids[0];
_accrueInterest(id);
uint256 totalOwed = credits[id].principal + credits[id].interestAccrued;
// borrower deposits remaining balance not already repaid and held in contract
bool success = IERC20(credits[id].token).transferFrom(
msg.sender,
address(this),
totalOwed
);
require(success, "Loan: deposit failed");
// clear the credit
_repay(id, totalOwed);
require(_close(id));
return true;
}
/**
* @dev - Transfers token used in credit position from msg.sender to Loan contract.
* @dev - callable by anyone
* @notice - see _repay() for more details
* @param amount - amount of `token` in `id` to pay back
*/
function depositAndRepay(uint256 amount)
external
override
whileBorrowing
returns (bool)
{
bytes32 id = ids[0];
_accrueInterest(id);
require(amount <= credits[id].principal + credits[id].interestAccrued);
bool success = IERC20(credits[id].token).transferFrom(
msg.sender,
address(this),
amount
);
require(success, "Loan: failed repayment");
_repay(id, amount);
return true;
}
\color{black}
\color{white}
When the borrower borrow()
deposit from the second position, repaying the debt will not be possible, as the whileBorrowing()
modifier would block that operation. At this point, the close(bytes32 id)
function can be called to close the first credit position unless the _accrueInterest()
internal function is called and interest is accrued, preventing the closing of the unpaid debt. borrow()``whileBorrowing()``close(bytes32 id)``_accrueInterest()
modifier whileBorrowing() {
require(ids.length > 0 && credits[ids[0]].principal > 0);
_;
}
\color{black} \color{white}
To escape from the situation, the borrower can still borrow()
any amount from the first position unless the lender does not withdraw all liquidity. The withdrawal operation accrues the interest, which cannot be paid, as the whileBorrowing()
modifier only considers the principal. Moreover, the borrower can't borrow()
anymore from the empty deposit. As a result, a deadlock occurs in the contract, and the borrower can't pay off the debt.borrow()``whileBorrowing()``borrow()
The root cause of this issue is the _sortIntoQ(bytes32 p)
function, which supposes to shift the credit position with the borrowed deposit to the beginning of the ids
collection, but it does not work as expected. Sample invalid flow:_sortIntoQ(bytes32 p)``ids
function _sortIntoQ(bytes32 p) internal returns (bool) {
uint256 len = ids.length;
uint256 _i = 0; // index that p should be moved to
for (uint256 i = 0; i < len; i++) {
bytes32 id = ids[i];
if (p != id) {
if (credits[id].principal > 0) continue; // `id` should be placed before `p`
_i = i; // index of first undrawn LoC found
} else {
if (_i == 0) return true; // `p` in earliest possible index
// swap positions
ids[i] = ids[_i];
ids[_i] = p;
}
}
return true;
}
borrow
10_000_000_000_000_000 tokens from the second position.ids
collection was not updated; the second credit position is not set to the first.
accrueInterest
function. Note that the interestAccrued
parameter value in the first credit
record is updated.depositAndRepay
with any value. Observe that the transaction reverted.depositAndClose
. Observe that the transaction reverted.close
the first credit position. Observe that the transaction reverted with Loan: close failed. credit owed
error.close
the second credit position. Observe that the transaction reverted with Loan: close failed. credit owed
error.withdraw
all liquidity.borrow
. Observe that the transaction reverted with a Loan: no liquidity
error.close
the first credit position again. Observe that the transaction reverted with Loan: close failed. credit owed
error.
1. All necessary contracts are deployed and initialized: RevenueToken, SimpleOracle, LoanLib, LineOfCredit.borrow
10_000_000_000_000_000 tokens from the second position.
borrow
5. Note that the ids
collection was not updated; the second credit position is not set to the first.
ids
5. Forward blockchain time for 1 second.accrueInterest
function. Note that the interestAccrued
parameter value in the first credit
record is updated.
accrueInterest``interestAccrued``credit
7. As the borrower, attempt to depositAndRepay
with any value. Observe that the transaction reverted.
depositAndRepay
8. As the borrower, attempt to depositAndClose
. Observe that the transaction reverted.
depositAndClose
9. As the borrower, attempt to close
the first credit position. Observe that the transaction reverted with Loan: close failed. credit owed
error.
close``Loan: close failed. credit owed
10. As the lender2, attempt to close
the second credit position. Observe that the transaction reverted with Loan: close failed. credit owed
error.
close``Loan: close failed. credit owed
11. As the lender1, withdraw
all liquidity.
withdraw
12. As the borrower, attempt to borrow
. Observe that the transaction reverted with a Loan: no liquidity
error.
borrow``Loan: no liquidity
13. As the borrower, attempt to close
the first credit position again. Observe that the transaction reverted with Loan: close failed. credit owed
error.
close``Loan: close failed. credit owed
\
SOLVED: The Debt DAO team
solved this issue in commit
78a2a57081ee1242ab71a09e1f28218de4e03b65: the _sortIntoQ()
function has now proper implementation. The borrowed line of credit now moves to the first position to pay off. SOLVED:Debt DAO team
78a2a57081ee1242ab71a09e1f28218de4e03b65_sortIntoQ()
\
// High
In the SpigotedLoan
contract, a borrower and arbiter can add a spigot. Both of them must agree on a percentage value in ownerSplit
parameter from SpigotSettings
structure. The ownerSplit
parameter determines the amount of revenue tokens that stays in SpigotController
contract and are used to pay off the debt.SpigotedLoan``ownerSplit``SpigotSettings``ownerSplit``SpigotController
function addSpigot(
address revenueContract,
SpigotController.SpigotSettings calldata setting
) external mutualConsent(arbiter, borrower) returns (bool) {
return spigot.addSpigot(revenueContract, setting);
}
struct SpigotSettings {
address token; // token to claim as revenue from contract
uint8 ownerSplit; // x/100 % to Owner, rest to Treasury
bytes4 claimFunction; // function signature on contract to call and claim revenue
bytes4 transferOwnerFunction; // function signature on conract to call and transfer ownership
}
\color{black}
\color{white}
The updateOwnerSplit()
function can be used to update the ownerSplit
parameter in particular spigot basing on loan health. If the loan status is active, then defaultRevenueSplit
is applied. The defaultRevenueSplit
parameter is set in constructor. Based on unit tests, this parameter should have 10
as a value.updateOwnerSplit()``ownerSplit``defaultRevenueSplit``defaultRevenueSplit``10
/**
* @notice changes the revenue split between borrower treasury and lan repayment based on loan health
* @dev - callable `arbiter` + `borrower`
* @param revenueContract - spigot to update
*/
function updateOwnerSplit(address revenueContract) external returns (bool) {
(, uint8 split, , bytes4 transferFunc) = spigot.getSetting(
revenueContract
);
require(transferFunc != bytes4(0), "SpgtLoan: no spigot");
if (
loanStatus == LoanLib.STATUS.ACTIVE && split != defaultRevenueSplit
) {
// if loan is healthy set split to default take rate
spigot.updateOwnerSplit(revenueContract, defaultRevenueSplit);
} else if (
loanStatus == LoanLib.STATUS.LIQUIDATABLE && split != MAX_SPLIT
) {
// if loan is in distress take all revenue to repay loan
spigot.updateOwnerSplit(revenueContract, MAX_SPLIT);
}
return true;
}
\color{black}
\color{white}
The assessment revealed that the updateOwnerSplit()
function could be abused by the borrower or the lender, depending on the situation. If the ownerSplit
parameter is set below the defaultRevenueSplit
parameter, the lender can call it to increase the split percentage. Then more revenue would be used to pay off the debt. If the ownerSplit
parameter is set above the defaultRevenueSplit
parameter, the borrower can call it to decrease the split percentage. Then less revenue would be used to pay off the debt, and more revenue would return to the treasury (which is by default set to the borrower). Moreover, the healthy loan is always set to ACTIVE
. updateOwnerSplit()``ownerSplit``defaultRevenueSplit``ownerSplit``defaultRevenueSplit``ACTIVE
Additionally, based on the comment, the function is meant to be called by the arbiter or the borrower; however, no such authorization check is implemented.
SpigotedLoan
set the defaultRevenueSplit
parameter to 10
.addSpigot()
function. Set the ownerSplit
parameter to 5
.updateOwnerSplit()
function for the spigot added in the previous step. Note that the ownerSplit
parameter is now set to 10
.addSpigot()
function. Set the ownerSplit
parameter to 20
.updateOwnerSplit()
function for the spigot added in the previous step. Note that the ownerSplit
parameter is now set to 10
.1. All necessary contracts are deployed and initialized: CreditToken, RevenueToken, SimpleOracle, LoanLib, SpigotedLoan, SimpleRevenueContract. In the SpigotedLoan
set the defaultRevenueSplit
parameter to 10
.
SpigotedLoan``defaultRevenueSplit``10
2. As borrower and arbiter, add a new spigot with the addSpigot()
function. Set the ownerSplit
parameter to 5
.
addSpigot()``ownerSplit``5
3. As the lender, call the updateOwnerSplit()
function for the spigot added in the previous step. Note that the ownerSplit
parameter is now set to 10
.
updateOwnerSplit()``ownerSplit``10
4. As borrower and arbiter, add a new spigot with the addSpigot()
function. Set the ownerSplit
parameter to 20
.
addSpigot()``ownerSplit``20
5. As the borrower, call the updateOwnerSplit()
function for the spigot added in the previous step. Note that the ownerSplit
parameter is now set to 10
.updateOwnerSplit()``ownerSplit``10
\
RISK ACCEPTED: The Debt DAO team
accepted the risk of this finding. The implementation is transparent. The assumption is that default values should always be selected.RISK ACCEPTED:Debt DAO team
\
// High
In the SpigotedLoan
contract, the borrower can add spigots with revenue contracts that will support repayment of the debt. The revenue contract earns revenue tokens that later can be exchanged for the credit tokens using the claimAndRepay()
and claimAndTrade()
functions. These functions call the _claimAndTrade()
internal function responsible for claiming escrowed tokens from the SpigotController
contract and trading the claimed tokens. Also, the _claimAndTrade()
function updates the unusedTokens
collection with claimed tokens (revenue tokens) that were not traded.SpigotedLoan``claimAndRepay()``claimAndTrade()``_claimAndTrade()``SpigotController``_claimAndTrade()``unusedTokens
The assessment revealed that it is possible to trade less revenue tokens than previously claimed. The rest of the claimed tokens are locked in the SpigotedLoan
contract. They remain locked until the debt is paid off by other means, and the borrower can transfer all tokens from unusedTokens
collection using the sweep()
function. The SpigotedLoan
contract has no other function that allows to trade and repay revenue tokens that were previously claimed.SpigotedLoan``unusedTokens``sweep()``SpigotedLoan
This vulnerability remains the same for both cases when the revenue token is either ERC20 token or ether.
This vulnerability can also be abused by the borrower to use less revenue tokens to pay off the debt than was intended, making the revenue split mechanism ineffective.
function _claimAndTrade(
address claimToken,
address targetToken,
bytes calldata zeroExTradeData
) internal returns (uint256 tokensBought) {
uint256 existingClaimTokens = IERC20(claimToken).balanceOf(
address(this)
);
uint256 existingTargetTokens = IERC20(targetToken).balanceOf(
address(this)
);
uint256 tokensClaimed = spigot.claimEscrow(claimToken);
if (claimToken == address(0)) {
// if claiming/trading eth send as msg.value to dex
(bool success, ) = swapTarget.call{value: tokensClaimed}(
zeroExTradeData
);
require(success, "SpigotCnsm: trade failed");
} else {
IERC20(claimToken).approve(
swapTarget,
existingClaimTokens + tokensClaimed
);
(bool success, ) = swapTarget.call(zeroExTradeData);
require(success, "SpigotCnsm: trade failed");
}
uint256 targetTokens = IERC20(targetToken).balanceOf(address(this));
// ideally we could use oracle to calculate # of tokens to receive
// but claimToken might not have oracle. targetToken must have oracle
// underflow revert ensures we have more tokens than we started with
tokensBought = targetTokens - existingTargetTokens;
emit TradeSpigotRevenue(
claimToken,
tokensClaimed,
targetToken,
tokensBought
);
// update unused if we didnt sell all claimed tokens in trade
// also underflow revert protection here
unusedTokens[claimToken] +=
IERC20(claimToken).balanceOf(address(this)) -
existingClaimTokens;
}
borrow()
all deposits.addSpigot()
function and RevenueContract
as input. Set the ownerSplit
parameter to 10
.RevenueContract
contract to the SpigotController.RevenueContract
contract to simulate token gain.claimRevenue()
in SpigotController contract to claim revenue tokens. Note that 90% of tokens (450_000_000_000_000) are transferred to the treasury (which is the borrower), the 10% (50_000_000_000_000) of tokens are transferred to the SpigotController.1. All necessary contracts are deployed and initialized: CreditToken, RevenueToken, SimpleOracle, LoanLib, SpigotedLoan, SimpleRevenueContract.borrow()
all deposits.
borrow()
2. As borrower and arbiter, add new spigot with addSpigot()
function and RevenueContract
as input. Set the ownerSplit
parameter to 10
.
addSpigot()``RevenueContract``ownerSplit``10
3. As the borrower, transfer the ownership of the RevenueContract
contract to the SpigotController.
RevenueContract
4. Mint 500_000_000_000_000 revenue tokens to the RevenueContract
contract to simulate token gain.
RevenueContract
5. As borrower, call claimRevenue()
in SpigotController contract to claim revenue tokens. Note that 90% of tokens (450_000_000_000_000) are transferred to the treasury (which is the borrower), the 10% (50_000_000_000_000) of tokens are transferred to the SpigotController.claimRevenue()
claimAndRepay()
in the SpigotedLoan contract to trade escrowed revenue tokens and pay off part of the debt. As an input, trade exchange data provide 1_000_000_000_000 of revenue tokens that should be exchanged for credit tokens.unusedTokens
collection.6. As the borrower, call claimAndRepay()
in the SpigotedLoan contract to trade escrowed revenue tokens and pay off part of the debt. As an input, trade exchange data provide 1_000_000_000_000 of revenue tokens that should be exchanged for credit tokens.
claimAndRepay()
7. Observe the SpigotedLoan balances. Note that the contract has 1,000,000,000,000 credit tokens and 49,000,000,000,000 revenue tokens. Also, 49,000,000,000,000 revenue tokens are stored in unusedTokens
collection.unusedTokens
depositAndClose()
to pay off the debt.sweep()
function with revenue token address as an input to receive 49,000,000,000,000 revenue tokens from SpigotedLoan contract.8. As the borrower, call depositAndClose()
to pay off the debt.
depositAndClose()
9. As the borrower, call sweep()
function with revenue token address as an input to receive 49,000,000,000,000 revenue tokens from SpigotedLoan contract.sweep()
\
SOLVED: The Debt DAO team
solved this issue in commit
760c5bfb9c352fb681f8351253ff9776b176e357: the claimAndRepay()
function now allows you to trade unused revenue tokens to pay off debt.SOLVED:Debt DAO team
760c5bfb9c352fb681f8351253ff9776b176e357claimAndRepay()
\
// High
In the SpigotedLoan
contract, the borrower can add spigots with revenue contracts that will support repayment of the debt. The revenue contract earns revenue tokens that later can be exchanged for the credit tokens using the claimAndRepay()
and claimAndTrade()
functions. These functions call the _claimAndTrade()
internal function responsible for claiming escrowed tokens from the SpigotController
contract and trading the claimed tokens. It is possible to add a spigot with revenue contracts that earns revenue in ether. However, the assessment revealed that claiming and trading ether from a spigot is not possible in the _claimAndTrade()
function. This function assumes that the revenue token is an ERC20 token in every case. As a result, the escrowed ether remains locked until the debt is paid off by other means, and the borrower can transfer it from the SpigotController
contract.SpigotedLoan``claimAndRepay()``claimAndTrade()``_claimAndTrade()``SpigotController``_claimAndTrade()``SpigotController
function _claimAndTrade(
address claimToken,
address targetToken,
bytes calldata zeroExTradeData
) internal returns (uint256 tokensBought) {
uint256 existingClaimTokens = IERC20(claimToken).balanceOf(
address(this)
);
uint256 existingTargetTokens = IERC20(targetToken).balanceOf(
address(this)
);
uint256 tokensClaimed = spigot.claimEscrow(claimToken);
if (claimToken == address(0)) {
// if claiming/trading eth send as msg.value to dex
(bool success, ) = swapTarget.call{value: tokensClaimed}(
zeroExTradeData
);
require(success, "SpigotCnsm: trade failed");
} else {
IERC20(claimToken).approve(
swapTarget,
existingClaimTokens + tokensClaimed
);
(bool success, ) = swapTarget.call(zeroExTradeData);
require(success, "SpigotCnsm: trade failed");
}
uint256 targetTokens = IERC20(targetToken).balanceOf(address(this));
// ideally we could use oracle to calculate # of tokens to receive
// but claimToken might not have oracle. targetToken must have oracle
// underflow revert ensures we have more tokens than we started with
tokensBought = targetTokens - existingTargetTokens;
emit TradeSpigotRevenue(
claimToken,
tokensClaimed,
targetToken,
tokensBought
);
// update unused if we didnt sell all claimed tokens in trade
// also underflow revert protection here
unusedTokens[claimToken] +=
IERC20(claimToken).balanceOf(address(this)) -
existingClaimTokens;
}
borrow()
all deposits.addSpigot()
function and RevenueContract
as input. Set the revenue token to zero address (0x0). Note that zero address represents ether in the SpigotController contract.RevenueContract
contract to the SpigotController.RevenueContract
contract to simulate ether gain.claimRevenue()
in SpigotController contract to claim ether.claimAndTrade()
or claimAndRepay()
in the SpigotedLoan contract to trade escrowed ether and pay off part of the debt. borrow()
all deposits.
borrow()
2. As the borrower and arbiter, add a new spigot with addSpigot()
function and RevenueContract
as input. Set the revenue token to zero address (0x0). Note that zero address represents ether in the SpigotController contract.
addSpigot()``RevenueContract
3. As the borrower, transfer the ownership of the RevenueContract
contract to the SpigotController.
RevenueContract
4. Transfer 5_000_000_000_000_000_000 ether to the RevenueContract
contract to simulate ether gain.
RevenueContract
5. As the borrower, call claimRevenue()
in SpigotController contract to claim ether.
claimRevenue()
6. As the borrower, attempt to call claimAndTrade()
or claimAndRepay()
in the SpigotedLoan contract to trade escrowed ether and pay off part of the debt.
claimAndTrade()``claimAndRepay()
7. Observe that the above transaction reverts. Note that ether remains in SpigotController.\
SOLVED: The Debt DAO team
solved this issue in commit
cd711d024ac7df475329d9dab4f88756d194d133: the solution now supports spigot earning in ether.SOLVED:Debt DAO team
cd711d024ac7df475329d9dab4f88756d194d133
\
// High
In the SpigotedLoan
contract, the borrower can add spigots with revenue contracts that will support repayment of the debt. The revenue contract earns revenue tokens
that later can be exchanged to the credit tokens
using the claimAndRepay()
and claimAndTrade()
functions. These functions call the _claimAndTrade()
internal function responsible for claiming escrowed tokens from the SpigotController
contract and trading the claimed tokens. Also, the claimAndTrade()
function updates the unusedTokens
collection with target tokens (credit tokens) that were traded. It is possible to add a new spigot with the revenue contract
that earns in credit tokens
. Then, the assumption is that in the _claimAndTrade()
function, the trade of the tokens is done with a ratio of 1-to-1, or the decentralized exchange contract simply returns a false
value (instead of revert). The project team confirmed this scenario. As a result, the unusedTokens
collection is updated twice for credit tokens
, first time in the _claimAndTrade()
function and second time in the claimAndTrade()
function.SpigotedLoan``revenue tokens``credit tokens``claimAndRepay()``claimAndTrade()``_claimAndTrade()``SpigotController``claimAndTrade()``unusedTokens``revenue contract``credit tokens``_claimAndTrade()``false``unusedTokens``credit tokens``_claimAndTrade()``claimAndTrade()
function claimAndTrade(address claimToken, bytes calldata zeroExTradeData)
external
whileBorrowing
returns (uint256 tokensBought)
{
require(msg.sender == borrower || msg.sender == arbiter);
address targetToken = credits[ids[0]].token;
tokensBought = _claimAndTrade(
claimToken,
targetToken,
zeroExTradeData
);
// add bought tokens to unused balance
unusedTokens[targetToken] += tokensBought;
}
function _claimAndTrade(
address claimToken,
address targetToken,
bytes calldata zeroExTradeData
) internal returns (uint256 tokensBought) {
uint256 existingClaimTokens = IERC20(claimToken).balanceOf(
address(this)
);
uint256 existingTargetTokens = IERC20(targetToken).balanceOf(
address(this)
);
uint256 tokensClaimed = spigot.claimEscrow(claimToken);
if (claimToken == address(0)) {
// if claiming/trading eth send as msg.value to dex
(bool success, ) = swapTarget.call{value: tokensClaimed}(
zeroExTradeData
);
require(success, "SpigotCnsm: trade failed");
} else {
IERC20(claimToken).approve(
swapTarget,
existingClaimTokens + tokensClaimed
);
(bool success, ) = swapTarget.call(zeroExTradeData);
require(success, "SpigotCnsm: trade failed");
}
uint256 targetTokens = IERC20(targetToken).balanceOf(address(this));
// ideally we could use oracle to calculate # of tokens to receive
// but claimToken might not have oracle. targetToken must have oracle
// underflow revert ensures we have more tokens than we started with
tokensBought = targetTokens - existingTargetTokens;
emit TradeSpigotRevenue(
claimToken,
tokensClaimed,
targetToken,
tokensBought
);
// update unused if we didnt sell all claimed tokens in trade
// also underflow revert protection here
unusedTokens[claimToken] +=
IERC20(claimToken).balanceOf(address(this)) -
existingClaimTokens;
}
\color{black}
\color{white}
This flaw impacts the sweep()
function, which reverts as the contract's balance has fewer tokens than recorded in the unusedTokens
collection. Thus, the tokens are locked in the contract.sweep()``unusedTokens
function sweep(address token) external returns (uint256) {
if (loanStatus == LoanLib.STATUS.REPAID) {
return _sweep(borrower, token);
}
if (loanStatus == LoanLib.STATUS.INSOLVENT) {
return _sweep(arbiter, token);
}
return 0;
}
function _sweep(address to, address token) internal returns (uint256 x) {
x = unusedTokens[token];
if (token == address(0)) {
payable(to).transfer(x);
} else {
require(IERC20(token).transfer(to, x));
}
delete unusedTokens[token];
}
SimpleRevenueContract
must earn in credit tokens
.borrow()
half of the deposit.addSpigot()
function and RevenueContract
as input. Set the ownerSplit
parameter to 100
.RevenueContract
contract to the SpigotController.credit tokens
to the RevenueContract
contract to simulate token gain.claimRevenue()
in SpigotController contract to claim revenue tokens. Note that 100% of tokens (10_000_000_000) are transferred to the SpigotController.claimAndTrade()
in SpigotedLoan contract to trade escrowed credit tokens. As an input, trade exchange data provide 10_000_000_000 revenue tokens (credit tokens) that should be exchanged for credit tokens.unusedTokens
collection.depositAndClose()
. Observe that credit is paid off. Note that the contract has 10,000,000,000 credit tokens.sweep()
function with credit token address as an input. Note that the function reverts the ERC20: transfer amount exceeds balance
error.1. All necessary contracts are deployed and initialized: CreditToken, RevenueToken, SimpleOracle, LoanLib, SpigotedLoan, SimpleRevenueContract. Note that SimpleRevenueContract
must earn in credit tokens
.
SimpleRevenueContract``credit tokens
2. As borrower and lender1, add credit position for 10_000_000_000_000_000 tokens.borrow()
half of the deposit.
borrow()
2. As borrower and arbiter, add new spigot with addSpigot()
function and RevenueContract
as input. Set the ownerSplit
parameter to 100
.
addSpigot()``RevenueContract``ownerSplit``100
3. As the borrower, transfer the ownership of the RevenueContract
contract to the SpigotController.
RevenueContract
4. Mint 10_000_000_000 credit tokens
to the RevenueContract
contract to simulate token gain.
credit tokens``RevenueContract
5. As the borrower, call claimRevenue()
in SpigotController contract to claim revenue tokens. Note that 100% of tokens (10_000_000_000) are transferred to the SpigotController.
claimRevenue()
6. As the borrower, call claimAndTrade()
in SpigotedLoan contract to trade escrowed credit tokens. As an input, trade exchange data provide 10_000_000_000 revenue tokens (credit tokens) that should be exchanged for credit tokens.
claimAndTrade()
7. Observe the SpigotedLoan balances. Note that the contract has 500,010,000,000,000 credit tokens. Also, 20,000,000,000 credit tokens are stored in unusedTokens
collection.
unusedTokens
8. As the borrower, call depositAndClose()
. Observe that credit is paid off. Note that the contract has 10,000,000,000 credit tokens.
depositAndClose()
9. As the borrower, attempt to call sweep()
function with credit token address as an input. Note that the function reverts the ERC20: transfer amount exceeds balance
error.sweep()``ERC20: transfer amount exceeds balance
\
SOLVED: The Debt DAO team
solved this issue in commit
760c5bfb9c352fb681f8351253ff9776b176e357: the solution now updates the unusedTokens
only once.SOLVED:Debt DAO team
760c5bfb9c352fb681f8351253ff9776b176e357unusedTokens
\
// High
In the EscrowedLoan
contract, the _healthcheck()
internal function is supposed to check if the loan has the correct collateral ratio. Otherwise, the loan status is set to LIQUIDATABLE
. The _healthcheck()
function base on the _getLatestCollateralRatio()
function, which returns 0 for empty collateral. Therefore, calling the healthcheck()
function at the beginning of the loan's life cycle will set the EscrowedLoan
contract's status to LIQUIDATABLE
.EscrowedLoan``_healthcheck()``LIQUIDATABLE``_healthcheck()``_getLatestCollateralRatio()``healthcheck()``EscrowedLoan``LIQUIDATABLE
When contract has LIQUIDATABLE
status, several spigot's functions can be called without authorisation: releaseSpigot()
, updateOwnerSplit()
, sweep()
. As a result, the loan can get malformed state:
LIQUIDATABLE``releaseSpigot()``updateOwnerSplit()``sweep()
- the spigot's owner can be set to the arbiter, and it will not be usable by EscrowedLoan (SpigotedLoan),
- the spigot's split can be set to 100%,
- the unused tokens from EscrowedLoan (SpigotedLoan) can be transferred to the arbiter if only some tokens were stored in the contract.- the spigot's owner can be set to the arbiter, and it will not be usable by EscrowedLoan (SpigotedLoan),
- the spigot's split can be set to 100%,
- the unused tokens from EscrowedLoan (SpigotedLoan) can be transferred to the arbiter if only some tokens were stored in the contract.
The borrower can set the contract's status back to ACTIVE
by adding collateral and repeatedly calling the healthcheck()
function.ACTIVE``healthcheck()
function _healthcheck() virtual internal returns(LoanLib.STATUS) {
if(escrow.getCollateralRatio() < escrow.minimumCollateralRatio()) {
return LoanLib.STATUS.LIQUIDATABLE;
}
return LoanLib.STATUS.ACTIVE;
}
/**
* @notice calculates the cratio
* @dev callable by anyone
* @return - the calculated cratio
*/
function getCollateralRatio() external returns (uint256) {
return _getLatestCollateralRatio();
}
/**
* @notice updates the cratio according to the collateral value vs loan value
* @dev calls accrue interest on the loan contract to update the latest interest payable
* @return the updated collateral ratio in 18 decimals
*/
function _getLatestCollateralRatio() internal returns (uint256) {
ILoan(loan).accrueInterest();
uint256 debtValue = ILoan(loan).getOutstandingDebt();
uint256 collateralValue = _getCollateralValue();
if (collateralValue == 0) return 0;
if (debtValue == 0) return MAX_INT;
return _percent(collateralValue, debtValue, 18);
}
addSpigot()
function and RevenueContract
as input. Set the ownerSplit
parameter to 10
.RevenueContract
contract to the SpigotController.healthcheck()
function. Note that the loan's status is set to LIQUIDATABLE
.RevenueToken
token as collateral.RevenueToken
tokens as collateral.borrow
all deposit. Observe that transaction reverts due to the (Loan: no op)
error.1. All necessary contracts are deployed and initialized: CreditToken, RevenueToken, SimpleOracle, LoanLib, EscrowedLoan.addSpigot()
function and RevenueContract
as input. Set the ownerSplit
parameter to 10
.
addSpigot()``RevenueContract``ownerSplit``10
4. As the borrower, transfer the ownership of the RevenueContract
contract to the SpigotController.
RevenueContract
5. As the attacker, call thehealthcheck()
function. Note that the loan's status is set to LIQUIDATABLE
.
healthcheck()``LIQUIDATABLE
6. As the arbiter, enable the RevenueToken
token as collateral.
RevenueToken
7. As the borrower, add 200_000_000_000_000 of RevenueToken
tokens as collateral.
RevenueToken
8. As the borrower, attempt to borrow
all deposit. Observe that transaction reverts due to the (Loan: no op)
error.borrow``(Loan: no op)
updateOwnerSplit()
function. Observe that owner split is now set to 100
.releaseSpigot()
function. Observe that spigot's owner's address has been changed.healthcheck()
function. Note that the loan's status is set to ACTIVE
.borrow
all deposits. Observe that transaction finishes successfully.9. As the attacker, call updateOwnerSplit()
function. Observe that owner split is now set to 100
.
updateOwnerSplit()``100
10. As the attacker, call releaseSpigot()
function. Observe that spigot's owner's address has been changed.
releaseSpigot()
11. As the borrower, call thehealthcheck()
function. Note that the loan's status is set to ACTIVE
.
healthcheck()``ACTIVE
12. As the borrower, borrow
all deposits. Observe that transaction finishes successfully.borrow
\
RISK ACCEPTED: The Debt DAO
accepted the risk of this finding. RISK ACCEPTED:Debt DAO
\
// High
The SecuredLoan
can get the LIQUIDATABLE
status in two cases: when the loan's deadline has passed or when the collateral ratio is below the threshold. When the loan has the LIQUIDATABLE
status, the arbiter can take control over the spigots and collateral and use the released funds to repay the debt for the lender. The assessment revealed that in some cases, the arbiter could not liquidate the collateral when the loan's deadline has passed, and the loan gets the LIQUIDATABLE
status. The Escrow's liquidate()
function has an assertion that checks if the collateral ratio value is below the threshold. The collateral ratio decreases while the loan's interest increases. Thus, it may take time to get the ratio low enough.
Additionally, the borrower can still release part of the collateral within this time.SecuredLoan``LIQUIDATABLE``LIQUIDATABLE``LIQUIDATABLE``liquidate()
// Liquidation
/**
* @notice - Forcefully take collateral from borrower and repay debt for lender
* @dev - only called by neutral arbiter party/contract
* @dev - `loanStatus` must be LIQUIDATABLE
* @dev - callable by `arbiter`
* @param positionId -the debt position to pay down debt on
* @param amount - amount of `targetToken` expected to be sold off in _liquidate
* @param targetToken - token in escrow that will be sold of to repay position
*/
function liquidate(
bytes32 positionId,
uint256 amount,
address targetToken
)
external
returns(uint256)
{
require(msg.sender == arbiter);
_updateLoanStatus(_healthcheck());
require(loanStatus == LoanLib.STATUS.LIQUIDATABLE, "Loan: not liquidatable");
// send tokens to arbiter for OTC sales
return _liquidate(positionId, amount, targetToken, msg.sender);
}
function _liquidate(
bytes32 positionId,
uint256 amount,
address targetToken,
address to
)
virtual internal
returns(uint256)
{
require(escrow.liquidate(amount, targetToken, to));
emit Liquidate(positionId, amount, targetToken);
return amount;
}
/**
* @notice liquidates borrowers collateral by token and amount
* @dev requires that the cratio is at or below the liquidation threshold
* @dev callable by `loan`
* @param amount - the amount of tokens to liquidate
* @param token - the address of the token to draw funds from
* @param to - the address to receive the funds
* @return - true if successful
*/
function liquidate(
uint256 amount,
address token,
address to
) external returns (bool) {
require(amount > 0, "Escrow: amount is 0");
require(
msg.sender == loan,
"Escrow: msg.sender must be the loan contract"
);
require(
minimumCollateralRatio > _getLatestCollateralRatio(),
"Escrow: not eligible for liquidation"
);
require(
deposited[token].amount >= amount,
"Escrow: insufficient balance"
);
deposited[token].amount -= amount;
require(IERC20(token).transfer(to, amount));
emit Liquidate(token, amount);
return true;
}
ttl
parameter to 1 day
. Set the minimumCollateralRatio
parameter to 10%
.10000
and facility rate set to 100
.RevenueToken
token as collateral.RevenueToken
tokens as collateral.borrow
all deposits. Note that the collateral ratio value is 15%``.1. All necessary contracts are deployed and initialized: CreditToken, RevenueToken, SimpleOracle, LoanLib, EscrowedLoan. Set the
ttlparameter to
1 day. Set the
minimumCollateralRatioparameter to
10%.
ttl1 day
minimumCollateralRatio10%`2. As the borrower and lender1, add credit position for 1_000_000_000_000_000 tokens withdrawn rate set to `10000` and facility rate set to `100`.
`10000
1003. As the arbiter, enable the
RevenueTokentoken as collateral.
RevenueToken4. As the borrower, add 150_000_000_000_000 of
RevenueTokentokens as collateral.
RevenueToken5. As the borrower,
borrowall deposits. Note that the collateral ratio value is
15%`.
borrow`healthcheck()
function. Note that the loan's status is set to LIQUIDATABLE
.liquidate()
function. Observe that the transaction reverts with the Escrow: not eligible for liquidation
error.releaseCollateral()
function. Observe that transaction finishes successfully. Note that the collateral ratio value is `14,9%``.6. Forward blockchain time for 1 day and 1 second.healthcheck()
function. Note that the loan's status is set to LIQUIDATABLE
.
healthcheck()``LIQUIDATABLE
8. As the arbiter, attempt to call liquidate()
function. Observe that the transaction reverts with the Escrow: not eligible for liquidation
error.
liquidate()``Escrow: not eligible for liquidation
9. As the borrower, call releaseCollateral()
function. Observe that transaction finishes successfully. Note that the collateral ratio value is 14,9%``.
releaseCollateral()`182 days
. Note that the collateral ratio value is below 10%
.liquidate()
function. Observe that the transaction finishes successfully.10. Forward blockchain time for 182 days
. Note that the collateral ratio value is below 10%
.
182 days``10%
11. As the arbiter, call liquidate()
function. Observe that the transaction finishes successfully.liquidate()
\
SOLVED: The Debt DAO team
solved this issue in commit
c882e7f2891efa4a053f28c0c5d4a439f694a397: the solution now allows the function liquidate()
to be called when the loan status is set to LIQUIDATABLE
regardless of the value of the collateral ratio.SOLVED:Debt DAO team
c882e7f2891efa4a053f28c0c5d4a439f694a397liquidate()``LIQUIDATABLE
\
// Medium
In the LineOfCredit
contract, a borrower and lender can add credit. The algorithm assumes that unused funds accrue interest based on the facility rate (frate
parameter). There is no requirement that the borrower must borrow the deposit upon closing the credit. The close(id)
function does not call _accrueInterest()
internal function. Hence, closing credit without accruing interest from unused funds is possible.LineOfCredit``frate``close(id)``_accrueInterest()
Scenario 1 - without accrueInterest function calling``Scenario 1 - without accrueInterest function calling
close
the credit. Observe that credit is closed without accruing interest from unused funds.1. All necessary contracts are deployed and initialized: RevenueToken, SimpleOracle, LoanLib, LineOfCredit.close
the credit. Observe that credit is closed without accruing interest from unused funds.close
Scenario 2 - with accrueInterest function calling``Scenario 2 - with accrueInterest function calling
accrueInterest()
function. Note that the interestAccrued()
parameter value in first credit
record is updated.close()
the credit. Observe that the transaction reverted with Loan: close failed. credit owed
error.1. All necessary contracts are deployed and initialized: RevenueToken, SimpleOracle, LoanLib, LineOfCredit.accrueInterest()
function. Note that the interestAccrued()
parameter value in first credit
record is updated.
accrueInterest()``interestAccrued()``credit
5. As the borrower, attempt to close()
the credit. Observe that the transaction reverted with Loan: close failed. credit owed
error.close()``Loan: close failed. credit owed
\
SOLVED: The Debt DAO team
solved this issue in commit
1908ded5d604984fd1a6c52af360c9135582f175: the solution now forces the borrower to pay accrued interest on unused funds by calling the close()
function.SOLVED:Debt DAO team
1908ded5d604984fd1a6c52af360c9135582f175close()
\
// Medium
In the LineOfCredit
contract, the borrower has two possibilities to pay off the debt: by calling depositAndRepay()
and then close()
functions, or by calling a single depositAndClose()
function. The close()
function combined with depositAndRepay()
should allow the borrower to close the debit. The close(id)
function does not call _accrueInterest()
internal function.LineOfCredit``depositAndRepay()``close()``depositAndClose()``close()``depositAndRepay()``close(id)``_accrueInterest()
The assessment revealed that the lender can front-run close()
function called by the borrower with the accrueInterest()
function. As a result, the close()
function will revert, and a small amount of facility interest will be added to the debt. Eventually, the borrower could escape from the situation by using the depositAndClose()
function.close()``accrueInterest()``close()``depositAndClose()
borrow()
all deposits.depositAndRepay()
function.close()
the credit, but firstly call accrueInterest()
function as a lender to simulate front-running.close()
function reverts with Loan: close failed. credit owed
error. Note that a small amount of facility rate is accrued.1. All necessary contracts are deployed and initialized: RevenueToken, SimpleOracle, LoanLib, LineOfCredit.borrow()
all deposits.
borrow()
4. As the borrower, pay off the debt and interest using the depositAndRepay()
function.
depositAndRepay()
5. As borrower, attempt to close()
the credit, but firstly call accrueInterest()
function as a lender to simulate front-running.
close()``accrueInterest()
6. Observe that close()
function reverts with Loan: close failed. credit owed
error. Note that a small amount of facility rate is accrued.close()``Loan: close failed. credit owed
\
SOLVED: The Debt DAO team
solved this issue in commit
1908ded5d604984fd1a6c52af360c9135582f175: the solution now forces the borrower to pay accrued interest on unused funds by calling the close()
function.SOLVED:Debt DAO team
1908ded5d604984fd1a6c52af360c9135582f175close()
\
// Medium
In the SpigotedLoan
contract, the borrower can add spigots with revenue contracts that will support repayment of the debt. The revenue contract earns revenue tokens that later can be exchanged for the credit tokens using the claimAndRepay()
and claimAndTrade()
functions. These functions call the _claimAndTrade()
internal function responsible for claiming escrowed tokens from the SpigotController
contract and trading the claimed tokens. Also, the claimAndTrade()
function updates the unusedTokens
collection with target tokens (credit tokens) that were traded.SpigotedLoan``claimAndRepay()``claimAndTrade()``_claimAndTrade()``SpigotController``claimAndTrade()``unusedTokens
function claimAndTrade(address claimToken, bytes calldata zeroExTradeData)
external
whileBorrowing
returns (uint256 tokensBought)
{
require(msg.sender == borrower || msg.sender == arbiter);
address targetToken = credits[ids[0]].token;
tokensBought = _claimAndTrade(
claimToken,
targetToken,
zeroExTradeData
);
// add bought tokens to unused balance
unusedTokens[targetToken] += tokensBought;
}
In the SpigotController
contract, the _claimRevenue()
internal function is responsible for calculating the claimed token value. However, it reverts when no tokens can be claimed (line 140).
The assessment revealed that it is not possible to use traded credit tokens to pay off the debt until new revenue is claimed in the SpigotController
contract. The SpigotController
contract gains tokens from the revenue contract, which is a third-party component; thus, the point of time of new revenue arrival is uncertain.
The borrower can also abuse this vulnerability to trade revenue tokens but not use them to pay off the debt, making the spigot mechanism ineffective. SpigotController``_claimRevenue()``SpigotController``SpigotController
function _claimRevenue(address revenueContract, bytes calldata data, address token)
internal
returns (uint256 claimed)
{
uint256 existingBalance = _getBalance(token);
if(settings[revenueContract].claimFunction == bytes4(0)) {
// push payments
// claimed = total balance - already accounted for balance
claimed = existingBalance - escrowed[token];
} else {
// pull payments
require(bytes4(data) == settings[revenueContract].claimFunction, "Spigot: Invalid claim function");
(bool claimSuccess, bytes memory claimData) = revenueContract.call(data);
require(claimSuccess, "Spigot: Revenue claim failed");
// claimed = total balance - existing balance
claimed = _getBalance(token) - existingBalance;
}
require(claimed > 0, "Spigot: No revenue to claim");
if(claimed > MAX_REVENUE) claimed = MAX_REVENUE;
return claimed;
}
borrow()
all deposits.addSpigot()
function and RevenueContract
as input. Set the ownerSplit
parameter to 10
.RevenueContract
contract to the SpigotController.RevenueContract
contract to simulate token gain.claimRevenue()
in SpigotController contract to claim revenue tokens. Note that 90% of tokens (450_000_000_000_000) are transferred to the treasury (which is the borrower), the 10% (50_000_000_000_000) of tokens are transferred to the SpigotController.1. All necessary contracts are deployed and initialized: CreditToken, RevenueToken, SimpleOracle, LoanLib, SpigotedLoan, SimpleRevenueContract.borrow()
all deposits.
borrow()
2. As borrower and arbiter, add new spigot with addSpigot()
function and RevenueContract
as input. Set the ownerSplit
parameter to 10
.
addSpigot()``RevenueContract``ownerSplit``10
3. As the borrower, transfer the ownership of the RevenueContract
contract to the SpigotController.
RevenueContract
4. Mint 500_000_000_000_000 revenue tokens to the RevenueContract
contract to simulate token gain.
RevenueContract
5. As the borrower, call claimRevenue()
in SpigotController contract to claim revenue tokens. Note that 90% of tokens (450_000_000_000_000) are transferred to the treasury (which is the borrower), the 10% (50_000_000_000_000) of tokens are transferred to the SpigotController.claimRevenue()
claimAndTrade()
in the SpigotedLoan contract to trade escrowed revenue tokens. As an input, trade exchange data provide 50_000_000_000_000 revenue tokens that should be exchanged for credit tokens.6. As the borrower, call claimAndTrade()
in the SpigotedLoan contract to trade escrowed revenue tokens. As an input, trade exchange data provide 50_000_000_000_000 revenue tokens that should be exchanged for credit tokens.claimAndTrade()
Observe the SpigotedLoan balances. Note that the contract has 50,000,000,000,000 credit tokens. Also 50,000,000,000,000 credit tokens are stored in unusedTokens
collection.
As the borrower, attempt to call claimAndTrade()
or claimAndRepay()
functions. Observe that these functions revert with the error Spigot: No escrow to claim
.7. Observe the SpigotedLoan balances. Note that the contract has 50,000,000,000,000 credit tokens. Also 50,000,000,000,000 credit tokens are stored in unusedTokens
collection.
unusedTokens
8. As the borrower, attempt to call claimAndTrade()
or claimAndRepay()
functions. Observe that these functions revert with the error Spigot: No escrow to claim
.claimAndTrade()``claimAndRepay()``Spigot: No escrow to claim
RevenueContract
contract to simulate token gain.claimRevenue()
in the SpigotController contract to claim revenue tokens.claimAndRepay()
. Observe that all unused tokens were used to pay off part of the debt.9. Mint 1000 revenue tokens to the RevenueContract
contract to simulate token gain.
RevenueContract
10. As the borrower, call claimRevenue()
in the SpigotController contract to claim revenue tokens.
claimRevenue()
11. As the borrower, call claimAndRepay()
. Observe that all unused tokens were used to pay off part of the debt.claimAndRepay()
\
SOLVED: The Debt DAO team
solved this issue in commit
1908ded5d604984fd1a6c52af360c9135582f175: the solution now allows all traded credit tokens to be used via the useAndRepay()
function.SOLVED:Debt DAO team
1908ded5d604984fd1a6c52af360c9135582f175useAndRepay()
\
// Medium
In the SpigotController
, the claimRevenue()
function allows claiming revenue from the revenue contract, split it between treasury and escrow, and send part of it to the treasury. By default, the treasury is the borrower. The ownerSplit
parameter is the initial split agreed upon and set up by the borrower and the arbiter.
When the credit deadline has passed, and it has defaulted, the healthcheck()
function from the LineOfCredit
contract must be called explicitly to set loan status to LIQUIDATABLE
. Afterward, the arbiter can call the updateOwnerSplit()
function from the SpigotedLoan
contract to update the ownerSplit
parameter, so 100% of revenue is escrowed, and none is sent to the treasury.SpigotController``claimRevenue()``ownerSplit``healthcheck()``LineOfCredit``LIQUIDATABLE``updateOwnerSplit()``SpigotedLoan``ownerSplit
/**
* @notice - Claim push/pull payments through Spigots.
Calls predefined function in contract settings to claim revenue.
Automatically sends portion to treasury and escrows Owner's share.
* @dev - callable by anyone
* @param revenueContract Contract with registered settings to claim revenue from
* @param data Transaction data, including function signature, to properly claim revenue on revenueContract
* @return claimed - The amount of tokens claimed from revenueContract and split in payments to `owner` and `treasury`
*/
function claimRevenue(address revenueContract, bytes calldata data)
external nonReentrant
returns (uint256 claimed)
{
address token = settings[revenueContract].token;
claimed = _claimRevenue(revenueContract, data, token);
// split revenue stream according to settings
uint256 escrowedAmount = claimed * settings[revenueContract].ownerSplit / 100;
// update escrowed balance
escrowed[token] = escrowed[token] + escrowedAmount;
// send non-escrowed tokens to Treasury if non-zero
if(claimed > escrowedAmount) {
require(_sendOutTokenOrETH(token, treasury, claimed - escrowedAmount));
}
emit ClaimRevenue(token, claimed, escrowedAmount, revenueContract);
return claimed;
}
/**
* @notice changes the revenue split between borrower treasury and lan repayment based on loan health
* @dev - callable `arbiter` + `borrower`
* @param revenueContract - spigot to update
*/
function updateOwnerSplit(address revenueContract) external returns (bool) {
(, uint8 split, , bytes4 transferFunc) = spigot.getSetting(
revenueContract
);
require(transferFunc != bytes4(0), "SpgtLoan: no spigot");
if (
loanStatus == LoanLib.STATUS.ACTIVE && split != defaultRevenueSplit
) {
// if loan is healthy set split to default take rate
spigot.updateOwnerSplit(revenueContract, defaultRevenueSplit);
} else if (
loanStatus == LoanLib.STATUS.LIQUIDATABLE && split != MAX_SPLIT
) {
// if loan is in distress take all revenue to repay loan
spigot.updateOwnerSplit(revenueContract, MAX_SPLIT);
}
return true;
}
\color{black}
\color{white}
The assessment revealed that the loan's LIQUIDATABLE
status is not propagated to the spigots. Furthermore, claimRevenue()
function has no authorization implemented. As a result, the borrower can front-run the updateOwnerSplit()
function with the claimRevenue()
to obtain one more revenue share from spigot. LIQUIDATABLE``claimRevenue()``updateOwnerSplit()``claimRevenue()
ttl
parameter to 1 day.borrow()
half of the deposit - 5_000_000_000_000_000.addSpigot()
function and Set the ownerSplit
parameter to 10
.RevenueContract
contract to the SpigotController.RevenueContract
contract to simulate token gain.borrow()
the remaining deposit. Observe that the transaction reverted with the Loan: can't borrow
error.1. All necessary contracts are deployed and initialized: CreditToken, RevenueToken, SimpleOracle, LoanLib, SpigotedLoan, SimpleRevenueContract. Set the ttl
parameter to 1 day.
ttl
2. As borrower and lender1, add credit position for 10_000_000_000_000_000 tokens.borrow()
half of the deposit - 5_000_000_000_000_000.
borrow()
2. As borrower and arbiter, add a new spigot with the addSpigot()
function and Set the ownerSplit
parameter to 10
.
addSpigot()``ownerSplit``10
3. As the borrower, transfer the ownership of the RevenueContract
contract to the SpigotController.
RevenueContract
4. Mint 500_000_000_000_000 revenue tokens to the RevenueContract
contract to simulate token gain.
RevenueContract
5. Forward blockchain time for 1 day and 1 second.borrow()
the remaining deposit. Observe that the transaction reverted with the Loan: can't borrow
error.borrow()``Loan: can't borrow
healthcheck()
function. Note that the loan's status changed from ACTIVE
to LIQUIDATABLE
.claimRevenue()
to simulate front-run of the updateOwnerSplit()
function, so 90% of revenue will go to the borrower and 10% go to the SpigotController contract.7. As the arbiter, call the healthcheck()
function. Note that the loan's status changed from ACTIVE
to LIQUIDATABLE
.
healthcheck()``ACTIVE``LIQUIDATABLE
8. As the borrower, call claimRevenue()
to simulate front-run of the updateOwnerSplit()
function, so 90% of revenue will go to the borrower and 10% go to the SpigotController contract.claimRevenue()``updateOwnerSplit()
\
RISK ACCEPTED: The Debt DAO
accepted the risk of this finding. RISK ACCEPTED:Debt DAO
\
// Medium
In the Escrow
contract, the minimumCollateralRatio
parameter defines the minimum threshold of collateral ratio that allows the borrower to borrow the deposit (using the _healthcheck()
function). Basing on project's documentation this parameter is expected to have 18 decimals, e.g. 1 ether = 100%. However, the contract does not implement any input validation, thus prone to human errors. A user could set too small a value, e.g., 100, which would make the mechanism ineffective. No function to update the minimumCollateralRatio
parameter is available in the contract.Escrow``minimumCollateralRatio``_healthcheck()
project's documentationminimumCollateralRatio
// the minimum value of the collateral in relation to the outstanding debt e.g. 10% of outstanding debt
uint256 public minimumCollateralRatio;
constructor(
uint256 _minimumCollateralRatio,
address _oracle,
address _loan,
address _borrower
) public {
minimumCollateralRatio = _minimumCollateralRatio;
oracle = _oracle;
loan = _loan;
borrower = _borrower;
}
function _healthcheck() virtual internal returns(LoanLib.STATUS) {
if(escrow.getCollateralRatio() < escrow.minimumCollateralRatio()) {
return LoanLib.STATUS.LIQUIDATABLE;
}
return LoanLib.STATUS.ACTIVE;
}
minimumCollateralRatio
parameter in the EscrowedLoan
contract, provide a small value, e.g. 100.EscrowedLoan
contract deployed successfully.minimumCollateralRatio()
function. Observe the result, note that this confirms the contract's constructor appceted too low value for the minimumCollateralRatio
parameter.1. All necessary contracts are deployed and initialized: CreditToken, RevenueToken, SimpleOracle, LoanLib, EscrowedLoan. For the minimumCollateralRatio
parameter in the EscrowedLoan
contract, provide a small value, e.g. 100.
minimumCollateralRatio``EscrowedLoan
2. Observe that the EscrowedLoan
contract deployed successfully.
EscrowedLoan
3. As any account, call the minimumCollateralRatio()
function. Observe the result, note that this confirms the contract's constructor appceted too low value for the minimumCollateralRatio
parameter.minimumCollateralRatio()``minimumCollateralRatio
\
SOLVED: The Debt DAO team
solved this issue in commit
f07592950e62c9e797ab4e30d17f02a74d4165c6: the _minimumCollateralRatio
parameter now uses the uint32 type. The logic is updated, so the parameters use only 2 decimal points, e.g., 10000 = 100%. Such an approach minimizes human error only by setting a collateral of 0.01%. SOLVED:Debt DAO team
f07592950e62c9e797ab4e30d17f02a74d4165c6_minimumCollateralRatio
\
// Medium
In the Spigot
contract, the removeSpigot()
function allows the contract's owner to transfer tokens held by the spigot, and transfer the ownership of the revenueContract
to the operator (the borrower) and remove the revenueContract's data from spigot's settings collection. The amount of tokens to transfer is based on the escrowed
collection. The Spigot
allows multiple revenueContract
contracts, and such contracts can provide revenue in the same revenue tokens. In such circumstances, the first call to the removeSpigot()
function will transfer all escrowed tokens from every related revenueContract
, but escrowed[token]
will not be reset. Any subsequent call will revert, as a function would attempt to transfer escrowed tokens with an empty contract's balance. As a result, transferring the ownership of the remaining revenueContract
will not be possible. However, the contract's owner could escape from this situation by transferring the tokens back to the contract, which is cumbersome. Spigot``removeSpigot()``revenueContract``escrowed``Spigot``revenueContract``removeSpigot()``revenueContract``escrowed[token]``revenueContract
function removeSpigot(address revenueContract) external returns (bool) {
require(msg.sender == owner);
address token = settings[revenueContract].token;
uint256 claimable = escrowed[token];
if(claimable > 0) {
require(_sendOutTokenOrETH(token, owner, claimable));
emit ClaimEscrow(token, claimable, owner);
}
(bool success, bytes memory callData) = revenueContract.call(
abi.encodeWithSelector(
settings[revenueContract].transferOwnerFunction,
operator // assume function only takes one param that is new owner address
)
);
require(success);
delete settings[revenueContract];
emit RemoveSpigot(revenueContract, token);
return true;
}
SimpleRevenueContract
with RevenueToken.SimpleRevenueContract
with RevenueToken.addSpigot()
function and the first SimpleRevenueContract
as input.addSpigot()
function and the second SimpleRevenueContract
as input. SimpleRevenueContract
contract to the SpigotController.SimpleRevenueContract
contract to the SpigotController.SimpleRevenueContract
contract to simulate token gain.SimpleRevenueContract
contract to simulate token gain.borrow()
all deposits.SimpleRevenueContract
contract in SpigotController.SimpleRevenueContract
contract in SpigotController.SpigotedLoan
controller.removeSpigot()
function with the first SimpleRevenueContract
contract as an input.removeSpigot()
function with the second SimpleRevenueContract
contract as an input. Observe that the transaction reverts with the ERC20: transfer amount exceeds balance
error. Note that the contract's ownership still belongs to the SpigotController.1. All necessary contracts are deployed and initialized: CreditToken, RevenueToken, SimpleOracle, LoanLib, SpigotedLoan.SimpleRevenueContract
with RevenueToken.
SimpleRevenueContract
3. Deploy the second SimpleRevenueContract
with RevenueToken.
SimpleRevenueContract
4. As borrower and lender, add credit position for 1_000_000_000_000_000 tokens.addSpigot()
function and the first SimpleRevenueContract
as input.
addSpigot()``SimpleRevenueContract
6. As borrower and arbiter, add a second spigot with addSpigot()
function and the second SimpleRevenueContract
as input.
addSpigot()``SimpleRevenueContract
7. As the borrower, transfer the ownership of the first SimpleRevenueContract
contract to the SpigotController.
SimpleRevenueContract
8. As the borrower, transfer the ownership of the second SimpleRevenueContract
contract to the SpigotController.
SimpleRevenueContract
9. Mint 10_000_000_000_000 revenue tokens to the first SimpleRevenueContract
contract to simulate token gain.
SimpleRevenueContract
10. Mint 10_000_000_000_000 revenue tokens to the second SimpleRevenueContract
contract to simulate token gain.
SimpleRevenueContract
11. As the borrower, borrow()
all deposits.
borrow()
12. As borrower, claim revenue for the first SimpleRevenueContract
contract in SpigotController.
SimpleRevenueContract
13. As borrower, claim revenue for the second SimpleRevenueContract
contract in SpigotController.
SimpleRevenueContract
14. As the borrower, pay off the debt.SpigotedLoan
controller.
SpigotedLoan
16. As borrower, call removeSpigot()
function with the first SimpleRevenueContract
contract as an input.
removeSpigot()``SimpleRevenueContract
17. As borrower, attempt to call removeSpigot()
function with the second SimpleRevenueContract
contract as an input. Observe that the transaction reverts with the ERC20: transfer amount exceeds balance
error. Note that the contract's ownership still belongs to the SpigotController.removeSpigot()``SimpleRevenueContract``ERC20: transfer amount exceeds balance
\
SOLVED: The Debt DAO team
solved this issue in commit
1908ded5d604984fd1a6c52af360c9135582f175: the spigot now does not transfer escrowed tokens in the removeSpigot()
function. Escrowed tokens can be claimed with the claimEscrow()
function.SOLVED:Debt DAO team
1908ded5d604984fd1a6c52af360c9135582f175removeSpigot()``claimEscrow()
\
// Low
In the SpigotedLoan
the solution assumes that the borrower adds a spigot with the revenue contract and transfers the ownership of the revenue contract to the SpigotController
. SpigotedLoan``SpigotController
In the SpigotedLoan
contract, the arbiter can call the updateWhitelist
function to allow or disallow execution of the particular function for the operator (and the operator is the borrower by default) in the context of the revenue contract.
The assessment revealed that a malicious arbiter could whitelist the ownership transfer function for the operator. Thus, the operator can transfer the ownership from SpigotController
back to the borrower using the _operate()
function. This function disallows to execute claimFunction()
; however, it does not disallow to execute transferOwnerFunction
.SpigotedLoan``updateWhitelist``SpigotController``_operate()``claimFunction()``transferOwnerFunction
/**
* @notice - Checks that operation is whitelisted by Spigot Owner and calls revenue contract with supplied data
* @param revenueContract - smart contracts to call
* @param data - tx data, including function signature, to call contracts with
*/
function _operate(address revenueContract, bytes calldata data) internal nonReentrant returns (bool) {
// extract function signature from tx data and check whitelist
require(whitelistedFunctions[bytes4(data)], "Spigot: Unauthorized action");
// cant claim revenue via operate() because that fucks up accounting logic. Owner shouldn't whitelist it anyway but just in case
require(settings[revenueContract].claimFunction != bytes4(data), "Spigot: Unauthorized action");
(bool success, bytes memory opData) = revenueContract.call(data);
require(success, "Spigot: Operation failed");
return true;
}
addSpigot()
function and RevenueContract as input.transferOwnership()
function from RevenueContract contract using updateWhitelist()
function and the transferOwnership()
selector as an input.operate()
function. Note that the encoded transferOwnership()
function selector and borrower's address must be provided as input.1. All necessary contracts are deployed and initialized: CreditToken, RevenueToken, SimpleOracle, LoanLib, SpigotedLoan, SimpleRevenueContract.addSpigot()
function and RevenueContract as input.
addSpigot()
3. As the borrower, transfer the ownership of the RevenueContract contract to the SpigotController.transferOwnership()
function from RevenueContract contract using updateWhitelist()
function and the transferOwnership()
selector as an input.
transferOwnership()``updateWhitelist()``transferOwnership()
4. As the borrower, transfer the ownership again using the operate()
function. Note that the encoded transferOwnership()
function selector and borrower's address must be provided as input.operate()``transferOwnership()
\
RISK ACCEPTED: The Debt DAO team
accepted the risk of this finding.RISK ACCEPTEDDebt DAO team
\
// Low
In the SpigotController
contract, the contract's owner can call the _updateWhitelist
function to allow or disallow execution of the particular function for the operator in the context of the revenue contract. Upon function execution, the UpdateWhitelistFunction
event is emitted with true
value, despite the actual value present in the allowed
parameter. As a result, the contract may emit false and misleading information when some function is disallowed.SpigotController``_updateWhitelist``UpdateWhitelistFunction``true``allowed
/**
* @notice - Allows Owner to whitelist function methods across all revenue contracts for Operator to call.
* @param func - smart contract function signature to whitelist
* @param allowed - true/false whether to allow this function to be called by Operator
*/
function _updateWhitelist(bytes4 func, bool allowed) internal returns (bool) {
whitelistedFunctions[func] = allowed;
emit UpdateWhitelistFunction(func, true);
return true;
}
\
SOLVED: The Debt DAO team
solved this issue in commit
f61ecfc20f8b2550fa9b98f3821571cbaa154295: the UpdateWhitelistFunction
event is now emitted with a variable, not a fixed one.SOLVED:Debt DAO team
f61ecfc20f8b2550fa9b98f3821571cbaa154295UpdateWhitelistFunction
\
// Low
The borrower can postpone borrowing the deposit (borrow()
function), which would start drawing interest accruing as far as the credit would be required. When the lender attempt to withdraw()
the deposit, the borrower could front-run it with the borrow()
function, and immediately call the repay()
function to pay off the debt, so no drawn interest would be applied, and all deposit still would be available. The borrower could repeat that until the credit deadline has passed, or the deposit would be required. During this time, the facility interest would still be accrued.borrow()``withdraw()``borrow()``repay()
\
RISK ACCEPTED: The Debt DAO team
accepted the risk of this finding. RISK ACCEPTED:Debt DAO team
\
// Low
In the Spigot
contract, the removeSpigot()
allows the contract's owner to transfer tokens held by the spigot, and transfer the ownership of the revenueContract
to the operator (the borrower) and remove the revenueContract's data from the spigot's settings collection.Spigot``removeSpigot()``revenueContract
The amount of tokens to transfer is based on the escrowed
collection. However, the contract's balance and the escrowed
collection may contain different values. The contract's balance may be affected by push payments or by the MAX_REVENUE
check in the _claimRevenue()
function. The contract's balance may have a higher value than recorded in the escrowed
collection. Thus, spigot removal may end up with an amount of tokens locked in the contract.
Adding a new spigot may be required to unlock the tokens, which is cumbersome. escrowed``escrowed``MAX_REVENUE``_claimRevenue()``escrowed
function removeSpigot(address revenueContract) external returns (bool) {
require(msg.sender == owner);
address token = settings[revenueContract].token;
uint256 claimable = escrowed[token];
if(claimable > 0) {
require(_sendOutTokenOrETH(token, owner, claimable));
emit ClaimEscrow(token, claimable, owner);
}
(bool success, bytes memory callData) = revenueContract.call(
abi.encodeWithSelector(
settings[revenueContract].transferOwnerFunction,
operator // assume function only takes one param that is new owner address
)
);
require(success);
delete settings[revenueContract];
emit RemoveSpigot(revenueContract, token);
return true;
}
\
SOLVED: The Debt DAO team
solved this issue in commit
f61ecfc20f8b2550fa9b98f3821571cbaa154295: the removeSpigot()
function is now protected with whileNoUnclaimedRevenue
modifier.SOLVED:Debt DAO team
f61ecfc20f8b2550fa9b98f3821571cbaa154295removeSpigot()``whileNoUnclaimedRevenue
\
// Low
In the LineOfCredit
contract, the increaseCredit()
allows borrowers and lenders to increase their credit. This function also allows transferring specified principal
to the borrower immediately. However, this function does not call the _sortIntoQ()
function, which updates the credit position in the repaid queue. As a result, the credit position with increased credit and the transferred principal may not be updated in the queue and left behind other credit positions with the repaid principal. In some rare scenarios, to update the queue, the borrower would be forced to close other credit positions or to borrow from such a position (if the deposit is available).LineOfCredit``increaseCredit()``principal``_sortIntoQ()
function increaseCredit(
bytes32 id,
address lender,
uint256 amount,
uint256 principal
)
external
override
whileActive
mutualConsent(lender, borrower)
returns (bool)
{
_accrueInterest(id);
require(principal <= amount, 'LoC: amount must be over princpal');
Credit memory credit = credits[id];
require(lender == credit.lender, 'LoC: only lender can increase');
require(IERC20(credit.token).transferFrom(
credit.lender,
address(this),
amount
), "Loan: no tokens to lend");
credit.deposit += amount;
int256 price = oracle.getLatestAnswer(credit.token);
emit IncreaseCredit(
id,
amount,
LoanLib.calculateValue( price, amount, credit.decimals)
);
if(principal > 0) {
require(
IERC20(credit.token).transfer(borrower, principal),
"Loan: no liquidity"
);
uint256 value = LoanLib.calculateValue(price, principal, credit.decimals);
credit.principal += principal;
principalUsd += value;
emit Borrow(id, principal, value);
}
credits[id] = credit;
return true;
}
\
RISK ACCEPTED: The Debt DAO team
accepted the risk of this finding. RISK ACCEPTED:Debt DAO team
\
// Informational
In all the loops, the counter variable is incremented using i++
. It is known that, in loops, using ++i
costs less gas per iteration than i++
.i++``++i``i++
LineOfCredit.sol
LineOfCredit.sol
- Line 94: for (uint256 i = 0; i < len; i++)
- Line 131: for (uint256 i = 0; i < len; i++)
- Line 159: for (uint256 i = 0; i < len; i++)
- Line 686: for (uint256 i = 0; i < len; i++)
- Line 94: for (uint256 i = 0; i < len; i++)
for (uint256 i = 0; i < len; i++)
- Line 131: for (uint256 i = 0; i < len; i++)
for (uint256 i = 0; i < len; i++)
- Line 159: for (uint256 i = 0; i < len; i++)
for (uint256 i = 0; i < len; i++)
- Line 686: for (uint256 i = 0; i < len; i++)``for (uint256 i = 0; i < len; i++)
Escrow.sol
Escrow.sol
- Line 88: for (uint256 i = 0; i < length; i++)
- Line 88: for (uint256 i = 0; i < length; i++)``for (uint256 i = 0; i < length; i++)
Spigot.sol
Spigot.sol
- Line 206: for(uint256 i = 0; i < data.length; i++)
- Line 206: for(uint256 i = 0; i < data.length; i++)``for(uint256 i = 0; i < data.length; i++)
LoanLib.sol
LoanLib.sol
- Line 122: for(uint i = 0; i < positions.length; i++)
- Line 151: for(uint i = 1; i < len; i++)
- Line 122: for(uint i = 0; i < positions.length; i++)
for(uint i = 0; i < positions.length; i++)
- Line 151: for(uint i = 1; i < len; i++)``for(uint i = 1; i < len; i++)
For example, based in the following test contract:
//SPDX-License-Identifier: MIT
pragma solidity 0.8.9;
contract test {
function postiincrement(uint256 iterations) public {
for (uint256 i = 0; i < iterations; i++) {
}
}
function preiincrement(uint256 iterations) public {
for (uint256 i = 0; i < iterations; ++i) {
}
}
}
We can see the difference in the gas costs:
\
SOLVED: The Debt DAO team
solved this issue in commit
227d484486cb71c638d9fbe3ee4fbbe8f935c7cf: all instances were fixed.SOLVED:Debt DAO team
227d484486cb71c638d9fbe3ee4fbbe8f935c7cf
\
// Informational
As i
is an uint256
, it is already initialized to 0. uint256 i = 0
reassigns the 0 to i
which wastes gas.i``uint256``uint256 i = 0``i
LineOfCredit.sol
LineOfCredit.sol
- Line 94: for (uint256 i = 0; i < len; i++)
- Line 131: for (uint256 i = 0; i < len; i++)
- Line 159: for (uint256 i = 0; i < len; i++)
- Line 684: uint256 _i = 0; // index that p should be moved to
- Line 686: for (uint256 i = 0; i < len; i++)
- Line 94: for (uint256 i = 0; i < len; i++)
for (uint256 i = 0; i < len; i++)
- Line 131: for (uint256 i = 0; i < len; i++)
for (uint256 i = 0; i < len; i++)
- Line 159: for (uint256 i = 0; i < len; i++)
for (uint256 i = 0; i < len; i++)
- Line 684: uint256 _i = 0; // index that p should be moved to
uint256 _i = 0; // index that p should be moved to
- Line 686: for (uint256 i = 0; i < len; i++)``for (uint256 i = 0; i < len; i++)
Escrow.sol
Escrow.sol
- Line 83: uint256 collateralValue = 0;
- Line 88: for (uint256 i = 0; i < length; i++)
- Line 83: uint256 collateralValue = 0;
uint256 collateralValue = 0;
- Line 88: for (uint256 i = 0; i < length; i++)``for (uint256 i = 0; i < length; i++)
Spigot.sol
Spigot.sol
- Line 206: for(uint256 i = 0; i < data.length; i++)
- Line 206: for(uint256 i = 0; i < data.length; i++)``for(uint256 i = 0; i < data.length; i++)
LoanLib.sol
LoanLib.sol
- Line 119: uint256 count = 0;
- Line 122: for(uint i = 0; i < positions.length; i++)
- Line 151: for(uint i = 1; i < len; i++)
- Line 119: uint256 count = 0;
uint256 count = 0;
- Line 122: for(uint i = 0; i < positions.length; i++)
for(uint i = 0; i < positions.length; i++)
- Line 151: for(uint i = 1; i < len; i++)``for(uint i = 1; i < len; i++)
\
SOLVED: The Debt DAO team
solved this issue in commit
227d484486cb71c638d9fbe3ee4fbbe8f935c7cf: all instances were fixed.SOLVED:Debt DAO team
227d484486cb71c638d9fbe3ee4fbbe8f935c7cf
\
// Informational
Several instances of assertions without messages were identified. The lack of message in require
assertion might be unfavorable for end users.require
LineOfCredit.sol
LineOfCredit.sol
- Line 69: require(ids.length > 0 && credits[ids[0]].principal > 0);
- Line 199: require(interestRate.setRate(id, drate, frate));
- Line 228: require(interestRate.setRate(id, drate, frate));
- Line 344: require(amount <= credits[id].principal + credits[id].interestAccrued)
- Line 401: require(_sortIntoQ(id));
- Line 491: require(_close(id));
- Line 69: require(ids.length > 0 && credits[ids[0]].principal > 0);
require(ids.length > 0 && credits[ids[0]].principal > 0);
- Line 199: require(interestRate.setRate(id, drate, frate));
require(interestRate.setRate(id, drate, frate));
- Line 228: require(interestRate.setRate(id, drate, frate));
require(interestRate.setRate(id, drate, frate));
- Line 344: require(amount <= credits[id].principal + credits[id].interestAccrued)
require(amount <= credits[id].principal + credits[id].interestAccrued)
- Line 401: require(_sortIntoQ(id));
require(_sortIntoQ(id));
- Line 491: require(_close(id));``require(_close(id));
SecuredLoan.sol
SecuredLoan.sol
- Line 53: require(msg.sender == arbiter);
- Line 53: require(msg.sender == arbiter);``require(msg.sender == arbiter);
SpigotedLoan.sol
SpigotedLoan.sol
- Line 97: require(msg.sender == borrower || msg.sender == arbiter);
- Line 142: require(msg.sender == borrower || msg.sender == arbiter);
- Line 229: require(msg.sender == arbiter);
- Line 97: require(msg.sender == borrower || msg.sender == arbiter);
require(msg.sender == borrower || msg.sender == arbiter);
- Line 142: require(msg.sender == borrower || msg.sender == arbiter);
require(msg.sender == borrower || msg.sender == arbiter);
- Line 229: require(msg.sender == arbiter);``require(msg.sender == arbiter);
Escrow.sol
Escrow.sol
- Line 144: require(msg.sender == ILoan(loan).arbiter());
- Line 144: require(msg.sender == ILoan(loan).arbiter());``require(msg.sender == ILoan(loan).arbiter());
Spigot.sol
Spigot.sol
- Line 154: require(msg.sender == owner);
- Line 193: require(msg.sender == operator);
- Line 205: require(msg.sender == operator);
- Line 243: require(msg.sender == operator);
- Line 256: require(revenueContract != address(this));
- Line 277: require(msg.sender == owner);
- Line 292: require(success);
- Line 277: require(msg.sender == owner);
- Line 315: require(msg.sender == owner);
- Line 330: require(msg.sender == operator);
- Line 345: require(msg.sender == treasury || msg.sender == operator);
- Line 330: require(msg.sender == operator);
- Line 362: require(msg.sender == owner);
- Line 154: require(msg.sender == owner);
require(msg.sender == owner);
- Line 193: require(msg.sender == operator);
require(msg.sender == operator);
- Line 205: require(msg.sender == operator);
require(msg.sender == operator);
- Line 243: require(msg.sender == operator);
require(msg.sender == operator);
- Line 256: require(revenueContract != address(this));
require(revenueContract != address(this));
- Line 277: require(msg.sender == owner);
require(msg.sender == owner);
- Line 292: require(success);
require(success);
- Line 277: require(msg.sender == owner);
require(msg.sender == owner);
- Line 315: require(msg.sender == owner);
require(msg.sender == owner);
- Line 330: require(msg.sender == operator);
require(msg.sender == operator);
- Line 345: require(msg.sender == treasury || msg.sender == operator);
require(msg.sender == treasury || msg.sender == operator);
- Line 330: require(msg.sender == operator);
require(msg.sender == operator);
- Line 362: require(msg.sender == owner);``require(msg.sender == owner);
EscrowedLoan.sol
EscrowedLoan.sol
- Line 55: require(escrow.liquidate(amount, targetToken, to));
- Line 55: require(escrow.liquidate(amount, targetToken, to));``require(escrow.liquidate(amount, targetToken, to));
\
PARTIALLY SOLVED: The Debt DAO team
partially solved this informational finding.PARTIALLY SOLVED:Debt DAO team
\
// Informational
The defaultRevenueSplit
parameter lacks input validation in the SpigotedLoan
contract.defaultRevenueSplit``SpigotedLoan
constructor(
address oracle_,
address arbiter_,
address borrower_,
address swapTarget_,
uint256 ttl_,
uint8 defaultRevenueSplit_
) LineOfCredit(oracle_, arbiter_, borrower_, ttl_) {
spigot = new SpigotController(address(this), borrower, borrower);
defaultRevenueSplit = defaultRevenueSplit_;
swapTarget = swapTarget_;
}
\
SOLVED: The Debt DAO team
solved this issue in commit
760c5bfb9c352fb681f8351253ff9776b176e357: the defaultRevenueSplit
parameter now has input validation.SOLVED:Debt DAO team
760c5bfb9c352fb681f8351253ff9776b176e357defaultRevenueSplit
\
// Informational
Within the LoanLib
contract, part of the code seems unused and redundant: calculateValue()
function, DEBT_TOKEN
constant, and some values from STATUS
enum: UNINITIALIZED, INITIALIZED, UNDERCOLLATERALIZED, DELINQUENT, LIQUIDATING, OVERDRAWN, DEFAULT, ARBITRATION, INSOLVENT. Note that INSOLVENT
is being used, but it is never set.LoanLib``calculateValue()``DEBT_TOKEN``STATUS``INSOLVENT
Within the EscrowedLoan
contract, the _liquidate()
function has positionId
input parameter. This variable is used only to emit the Liquidate
event from the IEscrowedLoan
interface. Apart from that, no processing related to the credit with positionId
is being done. Also, the liquidate()
function from Escrow
contract emits another Liquidate
event from IEscrow
interface. The positionId
parameter and emission of the Liquidate
event from the IEscrowedLoan
interface seem to be redundant.EscrowedLoan``_liquidate()``positionId``Liquidate``IEscrowedLoan``positionId``liquidate()``Escrow``Liquidate``IEscrow``positionId``Liquidate``IEscrowedLoan
address constant DEBT_TOKEN = address(0xdebf);
enum STATUS {
// ¿hoo dis
// Loan has been deployed but terms and conditions are still being signed off by parties
UNINITIALIZED,
INITIALIZED,
// ITS ALLLIIIIVVEEE
// Loan is operational and actively monitoring status
ACTIVE,
UNDERCOLLATERALIZED,
LIQUIDATABLE, // [#X
DELINQUENT,
// Loan is in distress and paused
LIQUIDATING,
OVERDRAWN,
DEFAULT,
ARBITRATION,
// Lön izz ded
// Loan is no longer active, successfully repaid or insolvent
REPAID,
INSOLVENT
}
function calculateValue(
int price,
uint256 amount,
uint8 decimals
)
internal
returns(uint256)
{
return _calculateValue(price, amount, decimals);
}
event Liquidate(bytes32 indexed positionId, uint256 indexed amount, address indexed token);
function _liquidate(
bytes32 positionId,
uint256 amount,
address targetToken,
address to
)
virtual internal
returns(uint256)
{
require(escrow.liquidate(amount, targetToken, to));
emit Liquidate(positionId, amount, targetToken);
return amount;
}
\
SOLVED: The Debt DAO team
solved this issue in commit
227d484486cb71c638d9fbe3ee4fbbe8f935c7cf: all instances were fixed. In addition, the _liquidate()
function will use the positionId
input parameter in a future release.SOLVED:Debt DAO team
227d484486cb71c638d9fbe3ee4fbbe8f935c7cf_liquidate()``positionId
\
// Informational
The SpigotController
contract inherits ReentrancyGuard
. The functions _operate()
, claimRevenue()
, and claimEscrow()
are protected by the nonReentrant
modifier. The functions claimRevenue()
and claimEscrow()
uses _sendOutTokenOrETH()
function to transfer the ether. However, the removeSpigot()
also uses _sendOutTokenOrETH()
function, but it is not protected by the nonReentrant
modifier, also it modifies the contract's state after transferring the ether.SpigotController``ReentrancyGuard``_operate()``claimRevenue()``claimEscrow()``nonReentrant``claimRevenue()``claimEscrow()``_sendOutTokenOrETH()``removeSpigot()``_sendOutTokenOrETH()``nonReentrant
function removeSpigot(address revenueContract) external returns (bool) {
require(msg.sender == owner);
address token = settings[revenueContract].token;
uint256 claimable = escrowed[token];
if(claimable > 0) {
require(_sendOutTokenOrETH(token, owner, claimable));
emit ClaimEscrow(token, claimable, owner);
}
(bool success, bytes memory callData) = revenueContract.call(
abi.encodeWithSelector(
settings[revenueContract].transferOwnerFunction,
operator // assume function only takes one param that is new owner address
)
);
require(success);
delete settings[revenueContract];
emit RemoveSpigot(revenueContract, token);
return true;
}
\color{black}
\color{white}
In the SpigotedLoan
the sweep()
function transfers ether, however, it is not protected by the nonReentrant
modifier, also it modifies the contract's state after transferring the ether.SpigotedLoan``sweep()``nonReentrant
* @notice - sends unused tokens to borrower if repaid or arbiter if liquidatable
- doesnt send tokens out if loan is unpaid but healthy
* @dev - callable by anyone
* @param token - token to take out
*/
function sweep(address token) external returns (uint256) {
if (loanStatus == LoanLib.STATUS.REPAID) {
return _sweep(borrower, token);
}
if (loanStatus == LoanLib.STATUS.INSOLVENT) {
return _sweep(arbiter, token);
}
return 0;
}
function _sweep(address to, address token) internal returns (uint256 x) {
x = unusedTokens[token];
if (token == address(0)) {
payable(to).transfer(x);
} else {
require(IERC20(token).transfer(to, x));
}
delete unusedTokens[token];
}
\
SOLVED: The Debt DAO team
solved this issue in commit
4c0fd8888c6d1daf64cbdf7db018119fc9b18730: the claimAndRepay()
, the claimAndTrade()
and the sweep()
functions now have the nonReentrant
modifier. The removeSpigot()
functionality no longer transfers tokens. SOLVED:Debt DAO team
4c0fd8888c6d1daf64cbdf7db018119fc9b18730claimAndRepay()``claimAndTrade()``sweep()``nonReentrant``removeSpigot()
\
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.
// Download the full report
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed