Skip to content
TwitterGithub

Teller audit result

Audit result, Sherlock21 min read

teller

Teller is a borrowing / lending protocol allowing lender to enter markets and accept borrower bids for loans. The loans can be collateralized with ERC20 and ERC721 tokens. Interests are accrued in the principal of the loan and repayment schedule of the loan is defined by the market parameters while value and duration of the loans are defined by the borrowers.

The code was reviewed as part of the Sherlock contest. I ranked 2nd out of 230 participants in this audit. I found 4 high and 8 medium vulnerabilities. I present my findings here.

High: Missing access control in setCollateralEscrowBeacon

Summary

The function to set the reference implementation of the escrow inside of CollateralManager has no access control. It can be used by an attack to set its own implementation as a reference.

The attacker could use an escrow where they are allowed to withdraw the collateral. They can use the LenderCommitmentForwarder to automatically make a bid with collateral and accept it. They can then withdraw their collateral and never repay the loan. They can repeat the operation until LenderCommitmentForwarder is drained out of funds.

Vulnerability Detail

TellerV2 and CollateralManager expose re-initializer functions. I believe the goal of these functions is to set values that were not present in the past versions of the contracts after an upgrade:

1function setLenderManager(address _lenderManager)
2 external
3 reinitializer(8)
4 onlyOwner
5 {
6 _setLenderManager(_lenderManager);
7 }
1function setCollateralEscrowBeacon(address _collateralEscrowBeacon)
2 external
3 reinitializer(2)
4 {
5 collateralEscrowBeacon = _collateralEscrowBeacon;
6 }

As we can see, setLenderManager uses the onlyOwner modifier and setCollateralEscrowBeacon has no protection.

setCollateralEscrowBeacon can be called by an attacker during upgrades of the protocol before the protocol team has the time to call it (or as a front-run tx).

If the re-initializer function has not been called at all by the protocol team, the problem can be even longer-lived. As the value of collateralEscrowBeacon is already set in initialize(), it could be that the re-initializer has never been called for some instances of the contract and is free to be abused:

1function initialize(address _collateralEscrowBeacon, address _tellerV2)
2 external
3 initializer
4 {
5 collateralEscrowBeacon = _collateralEscrowBeacon;
6 tellerV2 = ITellerV2(_tellerV2);
7 __Ownable_init_unchained();
8 }

Impact

Usually front-running initializer is not an important problem as the protocol team can simply re-deploy (or in this case re-upgrade) to fix / avoid the problem.

However in this case, the upgrade impacts a live ecosystem with value at stake.

As explained in the summary, the attacker can use a beacon with a reference implementation that allows them to withdraw the collateral at all times.

If a lender accepts a loan with collateral at that time, the attacker will be allowed to withdraw the collateral of the user, resulting in a loss of funds for both borrower and lender (borrower lost collateral and won't repay loan to lender).

The loan acceptance process is automated in LenderCommitmentForwarder and the attacker can simply open a loan through it, withdraw the collateral, and repeat the operation until no funds are left in the LenderCommitmentForwarder.

Code Snippet

LenderCommitmentForwarder automated loan opening feature:

https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/LenderCommitmentForwarder.sol#L300-L400

Tool used

Manual Review

Recommendation

Add access control to setCollateralEscrowBeacon


High: _repayLoan will fail if lender is blacklisted

Summary

The internal function that repays a loan _repayLoan attempts to transfer the loan token back to the lender. If the loan token implements a blacklist like the common USDC token, the transfer may be impossible and the repayment will fail.

This internal _repayLoan function is called during any partial / full repayment and during liquidation.

Vulnerability Detail

The function to repay the loan to the lender directly transfers the token to the lender:

1function _repayLoan(...) internal virtual {
2 ...
3 bid.loanDetails.lendingToken.safeTransferFrom(
4 _msgSenderForMarket(bid.marketplaceId),
5 lender,
6 paymentAmount
7 );
8 ...

This function is called by any function that attemps to repay a loan (including liquidate): https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/TellerV2.sol#L593 https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/TellerV2.sol#L615 https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/TellerV2.sol#L649 https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/TellerV2.sol#L690

Any of these functions will fail if loan lender is blacklisted by the token.

During repayment the loan lender is computed by:

1function getLoanLender(uint256 _bidId)
2 public
3 view
4 returns (address lender_)
5 {
6 lender_ = bids[_bidId].lender;
7
8 if (lender_ == address(lenderManager)) {
9 return lenderManager.ownerOf(_bidId);
10 }
11 }

If the lender controls a blacklisted address, they can use the lenderManager to selectively transfer the loan to / from the blacklisted whenever they want.

Impact

Any lender can prevent repayment of a loan and its liquidation. In particular, a lender can wait until a loan is almost completely repaid, transfer the loan to a blacklisted address (even one they do not control) to prevent the loan to be fully repaid / liquidated. The loan will default and borrower will not be able to withdraw their collateral.

This result in a guaranteed griefing attack on the collateral of a user.

If the lender controls a blacklisted address, they can additionally withdraw the collateral of the user.

I believe the impact is high since the griefing attack is always possible whenever lent token uses a blacklist, and results in a guaranteed loss of collateral.

Code Snippet

The function to withdraw collateral only works when loan is paid or transfer to lender when loan is defaulted:

1function withdraw(uint256 _bidId) external {
2 BidState bidState = tellerV2.getBidState(_bidId);
3 if (bidState == BidState.PAID) {
4 _withdraw(_bidId, tellerV2.getLoanBorrower(_bidId));
5 } else if (tellerV2.isLoanDefaulted(_bidId)) {
6 _withdraw(_bidId, tellerV2.getLoanLender(_bidId));
7 emit CollateralClaimed(_bidId);
8 } else {
9 revert("collateral cannot be withdrawn");
10 }
11 }

Tool used

Manual Review

Recommendation

Use a push/pull pattern for transferring tokens. Allow repayment of loan and withdraw the tokens of the user into TellerV2 (or an escrow) and allow lender to withdraw the repayment from TellerV2 (or the escrow). This way, the repayment will fail only if TellerV2 is blacklisted.


High: Anyone can commit collateral for someone else

Summary

There is no access control on CollateralManager.commitCollateral(). It takes as input the bid ID and the collateral info and commits the borrower of bid ID to the input collateral.

Anyone can call this function to commit any amount of any collateral for any bid ID.

When the loan is accepted, the committed to collateral will be taken from the user and used as collateral in an escrow.

Vulnerability Detail

No access control on commitCollateral():

1function commitCollateral(
2 uint256 _bidId,
3 Collateral[] calldata _collateralInfo
4 ) public returns (bool validation_) {
5 address borrower = tellerV2.getLoanBorrower(_bidId);
6 (validation_, ) = checkBalances(borrower, _collateralInfo);
7
8 if (validation_) {
9 for (uint256 i; i < _collateralInfo.length; i++) {
10 Collateral memory info = _collateralInfo[i];
11 _commitCollateral(_bidId, info);
12 }
13 }
14 }

There is no access control on any of the called functions down the call chain:

No access control on checkBalances() called by commitCollateral(): https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/CollateralManager.sol#L168-L173

No access control on _checkBalances(): https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/CollateralManager.sol#L450-L470

No access control on _checkBalance(): https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/CollateralManager.sol#L478-L504

No access control on _commitCollateral(): https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/CollateralManager.sol#L426-L442

Impact

It is necessary that the borrower of the attacked bidId called approve for the committed to tokens for them to be used as collateral when the lender approves the bid.

As a result, an attacker can prevent the bid to ever be successfully accepted by committing the bid to a collateral (or a value) that is not approved by the borrower.

It is a common pattern that user approves contracts they trust with the max value (type(uint256).max) even though they will only spend parts of their token. A user holding 100 USDC that wants to commit 50 USDC for a bid can be attacked (by evil lender or anyone else) to commit the full 100 USDC and have all their tokens held as collateral when the bid is accepted.

Even when user do not use type(uint256).max to approve the contract, if they approved 100 USDC but intend to commit 50 USDC for a certain bid and 50 USDC for another bid, they are vulnerable to the attack.

The result is a possible DOS attack on any loan, and a risk of funds for the borrowers.

Code Snippet

The function that withdraw collateral from user and puts it in escrow, called when loan is accepted: https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/CollateralManager.sol#L179-L199

Tool used

Manual Review

Recommendation

Only allow bid borrower or TellerV2 to call commitCollateral(). Please not that there are two commitCollateral() functions impacted, one for committing an array of collateral and one for committing a single collateral.


High: Borrower can front-run lender's acceptBid to lower/remove its collateral

Summary

The function TellerV2.submitBid() to submit a bid with collateral does not withdraw a borrower's collateral. It only commits to the values via collateralManager.commitCollateral().

When the lender calls lenderAcceptBid, the borrower can front-run the transaction and call collateralManager.commitCollateral() to commit to the previously committed to collateral with an amount of 0.

The lender will accept the bid but the escrow will be deployed with 0 collateral amount.

Vulnerability Detail

The function submitBid() with collateral calls collateralManager.commitCollateral(): https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/TellerV2.sol#L323-L326

commitCollateral() checks the user balances (i.e. the user owns the amount of committed token) and writes to storage the commitment, it does not withdraw the tokens: https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/CollateralManager.sol#L117-L130

https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/CollateralManager.sol#L426-L442

lenderAcceptBid() calls collateralManager.deployAndDeposit() which will deploy an escrow and deposit whatever is currently in the storage of collateralManager. There is no parameter passed by lender as to what collateral it expects: https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/TellerV2.sol#L510

Impact

Loaner expecting to accept a loan with collateral will accept a loan without any collateral. The borrower is free to never repay the loan. Borrower gains funds and lender loses funds.

Code Snippet

Tool used

Manual Review

Recommendation

Let lender specify what collateral it expects in lenderAcceptBid(). Or prevent anyone but TellerV2 to call commitCollateral() (you may not want that for reason outside the scope of this audit). Or prevent commitCollateral() to lower the committed to collateral values.


Medium: Unsafe use of AddressSet in LenderCommitmentForwarder allows unauthorized borrowers

Summary

The contract LenderCommitmentForwarder uses EnumerableSetUpgradeable.AddressSet for the set of addresses allowed to borrow from a lender for a certain commitment.

However, it unsafely uses the delete opcode on it, which does not clean the storage of mappings. This results in removed (previously authorized) borrowers to still be able to borrow from the lender.

Vulnerability Detail

The library EnumerableSetUpgradeable states:

1* [WARNING]
2 * ====
3 * Trying to delete such a structure from storage will likely result in data corruption, rendering the structure
4 * unusable.
5 * See https://github.com/ethereum/solidity/pull/11843[ethereum/solidity#11843] for more info.
6 *
7 * In order to clean an EnumerableSet, you can either remove all elements one by one or create a fresh instance using an
8 * array of EnumerableSet.
9 * ====

The contract can be found here

The AddressSet struct uses an inner Set struct:

1struct AddressSet {
2 Set _inner;
3 }
4
5 struct Set {
6 // Storage of set values
7 bytes32[] _values;
8 // Position of the value in the `values` array, plus 1 because index 0
9 // means a value is not in the set.
10 mapping(bytes32 => uint256) _indexes;
11 }

This means that delete addrsSet will only clear the _values array and not the _indexes. The function addrsSet.contains(value) will still return true for previously contained elements after the addrsSet was deleted.

1function contains(AddressSet storage set, address value) internal view returns (bool) {
2 return _contains(set._inner, bytes32(uint256(uint160(value))));
3 }
4
5 function _contains(Set storage set, bytes32 value) private view returns (bool) {
6 return set._indexes[value] != 0;
7 }

The delete opcode is used by LenderCommitmentForwarder to update the commitment borrowers:

1function updateCommitmentBorrowers(
2 uint256 _commitmentId,
3 address[] calldata _borrowerAddressList
4 ) public commitmentLender(_commitmentId) {
5 delete commitmentBorrowersList[_commitmentId];
6 _addBorrowersToCommitmentAllowlist(_commitmentId, _borrowerAddressList);
7 }

It is also used in deleteCommitment but that is probably safe: https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/LenderCommitmentForwarder.sol#L267-L274

The addressSet.contains(borrower) is used to determine if a borrower is allowed to open a loan from the commitment: https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/LenderCommitmentForwarder.sol#L328-L332

Impact

Borrowers that were once allowed to accept a commitment but later removed are still allowed to accept the commitment.

I understand the main reason for this restricted list is to allow for a limited set of addresses to automatically borrow from a lender without collateral. If unauthorized addresses borrow from the lender, they are free not to repay the loan. This results in a loss of funds for the lender.

Code Snippet

Tool used

Manual Review

Testing:

Add the following (failing) test in LenderCommitmentForwarder_Combined_Test.sol:

1function test_acceptCommitmentWithBorrowersArray_change() public {
2 uint256 commitmentId = 0;
3
4 Commitment storage commitment = _createCommitment(
5 CommitmentCollateralType.ERC20,
6 maxAmount
7 );
8
9 address[] memory newBorrowersArray = new address[](1);
10 newBorrowersArray[0] = address(marketOwner);
11
12 lender._updateCommitmentBorrowers(commitmentId, borrowersArray);
13
14 lender._updateCommitmentBorrowers(commitmentId, newBorrowersArray);
15
16 bool acceptCommitAsMarketOwnerFails;
17
18 try
19 borrower._acceptCommitment(
20 commitmentId,
21 100, //principal
22 maxAmount, //collateralAmount
23 0, //collateralTokenId
24 address(collateralToken),
25 minInterestRate,
26 maxLoanDuration
27 )
28 {} catch {
29 acceptCommitAsMarketOwnerFails = true;
30 }
31
32 assertEq(
33 acceptCommitAsMarketOwnerFails,
34 true,
35 "Should fail when accepting as invalid borrower"
36 );
37 }

Recommendation

Follow OZ's recommendation to remove elements one by one or use an array of addrsset instead (i.e. committers provide a completely new set of allowed borrowers and you add that set to the list of stored sets and you check only the most recent set in acceptCommitment()).


Medium: Loans are liquidateable even when honest if bidDefaultDuration < paymentCycle

Summary

The calculation to determine if a loan is liquidateable does not take into account the payment cycle duration. Under certain circumstances where the payment cycle is shorter than the bid default duration, an honest borrower may repay its loan correctly every cycle but end up being defaulted and liquidated by the lender.

Vulnerability Detail

The function to determined if a loan is defaulted checks if the last payment was done more than bidDefaultDuration[_bidId] seconds ago:

1function isLoanDefaulted(uint256 _bidId)
2 public
3 view
4 override
5 returns (bool)
6 {
7 return _canLiquidateLoan(_bidId, 0);
8 }
9...
10 function _canLiquidateLoan(uint256 _bidId, uint32 _liquidationDelay)
11 internal
12 view
13 returns (bool)
14 {
15 Bid storage bid = bids[_bidId];
16
17 // Make sure loan cannot be liquidated if it is not active
18 if (bid.state != BidState.ACCEPTED) return false;
19
20 if (bidDefaultDuration[_bidId] == 0) return false;
21
22 return (uint32(block.timestamp) -
23 _liquidationDelay -
24 lastRepaidTimestamp(_bidId) >
25 bidDefaultDuration[_bidId]);
26 }

The value for bidDefaultDuration is set during the bid submission and is taken from the marketRegistry: https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/TellerV2.sol#L376

This value is controlled by the owner of the market: https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/MarketRegistry.sol#L487-L511

Impact

An honest user doing its repayment every cycle may be considered defaulted if the market parameters allow it. This could be done by a mischievous market owner but also as a mistake. This will result in a borrower loan being defaulted and the borrower losing its collateral to the lender (or in the worst case to a liquidator) while the borrower honestly attempted to repay its loan.

I expected values for the payment cycle to be close to a week and default duration close to a month, however by checking the live contracts on mainnet in the MarketRegistry for marketId = 3 I see: paymentCycle = PaymentDefaultDuration = 2592000 (720 hours). I see similar values for other market id.

https://etherscan.io/address/0x5e30357d5136Bc4BfaDBA1ab341D0da09Fe7a9F1#readProxyContract

Code Snippet

Tool used

Manual Review

Recommendation

Consider a loan as defaulted only when the last payment has been made PaymentDefaultDuration after it was due. Otherwise make sure market owners cannot set PaymentDefaultDuration below paymentCycle.


Medium: Fee on transfer tokens not handled

Summary

The contest details state:

ERC20: any ERC721: any ERC777: none ERC1155: any FEE-ON-TRANSFER: any

However, contracts throughout the system do not take into account the potential fee paid for fee on transfer tokens. I.e. they do not use the balance of the receiving contract before/after transfer to compute the actual transferred value or do not provide for the potential fee.

Vulnerability Detail

In TellerV2 the transfer of the loan ERC20 token does not take into account a transfer fee: https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/TellerV2.sol#L513-L540 https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/TellerV2.sol#L747-L751

Same in CollateralManager: https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/CollateralManager.sol#L316-L386

Same in CollateralEscrowV1._depositCollateral https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/escrow/CollateralEscrowV1.sol#L111-L149

And CollateralEscrowV1._withdrawCollateral https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/escrow/CollateralEscrowV1.sol#L158-L194

Impact

If using fee on transfer tokens, recipient of the loan / protocol fee / market fee will receive less token than anticipated through TellerV2.

If using fee on transfer tokens for collateral, the CollateralManager will attempt to withdraw a set amount from the borrower and transfer that same amount to the escrow. If fees were taken, the whole amount will not be available to the CollateralManager and the transfer will fail.

If fees only occur on certain transfer or do not occur for multiple transfers in a transaction, it can be that the CollateralManager successfully transfer the collateral to the escrow and loan is open, but the borrower / lender / liquidator will not be able to withdraw its collateral from the escrow (via CollateralManager) when the loan is repaid / defaulted / liquidated due to the fee.

The result is an impossibility to use the protocol and a loss of funds under certain conditions for the borrower / lender / liquidator.

Code Snippet

Tool used

Manual Review

Recommendation

Explicitly state that you do not support fee on transfer tokens. Otherwise, check the balance of the receiving token before and after the transfer to compute the actual transferred value. This value needs to be stored for example in the escrow contract / collateral manager to be re-used during _withdraw() to send back the correct amount.

If you decide to support fee on transfer tokens, please note that an attack vector is also present in the repayment of a loan on TellerV2 where the borrower repays amount small enough that the whole fee covers the transfer and the lender receives (almost) no token back.


Medium: Racing condition in between withdrawing a defaulted loan collateral and repaying the loan

Summary

There is a racing condition in between the repayment of a late loan and the withdrawal of the collateral at stake by the lender.

Vulnerability Detail

The function to withdraw collateral from a loan CollateralManager.withdraw() does not update the state of the bid.

When a loan is defaulted, the user may realise they are late on payment and attempt to repay their loans. It could also be that chain congestion introduces delay in the user transaction and the user submitted the transaction when loan was not yet defaulted.

If the lender (or anyone) calls CollateralManager.withdraw() at the same time (or as an evil front-run), the collateral will be sent to the lender.

If the user only repays part of the loan, they are not entitled to withdraw their collateral and the TellerV2 contract will not interact with the CollateralManager. As a result, the borrower will repay debt towards the lender for a defaulted loan for which the collateral has already been withdrawn.

Impact

Borrower may repay defaulted loans if they send transactions close to the due date or after it. The defaulted loan may already have been liquidated by the lender. This is a loss of funds for the borrower.

Code Snippet

The _repayLoan() function does not check if loan is defaulted or if the collateral has been withdrawn, unless paymentAmount >= _owedAmount and _shouldWithdrawCollateral == true: https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/TellerV2.sol#L712-L762

CollateralManager.withdraw() does not update the status of the loan on TellerV2: https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/CollateralManager.sol#L250-L260

Tool used

Manual Review

Recommendation

Prevent borrower to repay defaulted loan by calling isLoanDefaulted() and reverting when true.

Otherwise, when they repay their loans check if the collateral has already been withdrawn. You can do this for example by adding a boolean mapping withdrawn[bidId] on CollateralManager.

The most intuitive fix would be to update the bid state to LIQUIDATED when collateral is withdrawn, but that requires the CollateralManager to call the TellerV2, or to move the withdraw function from CollateralManager to TellerV2. withdraw() would set the status of the loan to PAID / LIQUIDATED and call CollateralManager._withdraw().


CollateralManager.withdraw function lacks access control

Summary

There is no access control on CollateralManager.withdraw(), the function can be called by anyone.

Vulnerability Detail

The function checks if loan is paid and sends the collateral to borrower. If loan is defaulted, the function sends the collateral to lender.

However, the protocol implemented a liquidation feature that allows others to liquidate a loan by repaying the lent token to the lender and getting the collateral in exchange.

It could be that a loan is defaulted and that a lender awaits for the 24 hours grace period before others can liquidate the loan and a griefer calls CollateralManager.withdraw(), directly sending the collateral to the lender.

Impact

Lender cannot use the liquidation feature of the protocol and is forced to receive the collateral.

If the collateral uses tokens with limited number of transfers or fee on transfer, this results in a direct loss for the lender.

Code Snippet

withdraw() function with no access control: https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/escrow/CollateralEscrowV1.sol#L84-L103

liquidateLoanFull() has a 24 hour grace period due to the LIQUIDATION_DELAY used in isLoanLiquidateable: https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/TellerV2.sol#L676-L704 https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/TellerV2.sol#L938-L945

Tool used

Manual Review

Recommendation

Only allow lender to call withdraw() when the loan is defaulted. Probably a good idea to only allow borrower to call withdraw() when the loan is paid.


Medium: lenderAcceptBid vulnearble to changes in market fees

Summary

The details for the audit state:

Market owners should NOT be able to race-condition attack borrowers or lenders by changing market settings while bids are being submitted or accepted (while tx are in mempool). Care has been taken to ensure that this is not possible (similar in theory to sandwich attacking but worse as if possible it could cause unexpected and non-consentual interest rate on a loan) and further-auditing of this is welcome.

However, the market fee parameter can be abused by market owner to steal most of the loan amount paid by lender.

Vulnerability Detail

In lenderAcceptBid() the fee paid by lender is taken from marketPlace:

1function lenderAcceptBid(uint256 _bidId)
2 ...
3 amountToMarketplace = bid.loanDetails.principal.percent(
4 marketRegistry.getMarketplaceFee(bid.marketplaceId)
5 );
6 amountToBorrower =
7 bid.loanDetails.principal -
8 amountToProtocol -
9 amountToMarketplace;
10 ...
11 //transfer fee to marketplace
12 bid.loanDetails.lendingToken.safeTransferFrom(
13 sender,
14 marketRegistry.getMarketFeeRecipient(bid.marketplaceId),
15 amountToMarketplace
16 );
17
18 //transfer funds to borrower
19 bid.loanDetails.lendingToken.safeTransferFrom(
20 sender,
21 bid.receiver,
22 amountToBorrower
23 );
24 ...

The fee parameters taken from marketRegistry is controlled by the market owner: https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/MarketRegistry.sol#L621-L630

Impact

If the value of the market place fee changes in between the time the bid is submitted and the time it is accepted, the borrower will receive less / more token than anticipated.

An evil market owner can set the value of the marketplace fee to 100% - protocolFeesPercent when a lender accept a loan to steal all the loan amount. This results in a loss of funds for the borrower (borrower is supposed to repay something they did not receive) .

Code Snippet

Tool used

Manual Review

Recommendation

Take fee percent parameter as input of _submitBid() and compare them to the values in marketRegistry to make sure borrower agrees with them, revert if they differ. Store the values in the bid parameters and do not consult marketRegistry when loan is accepted (or revert when fee values differ).


Medium: Bid submission vulnerable to market parameters changes

Summary

The details for the audit state:

Market owners should NOT be able to race-condition attack borrowers or lenders by changing market settings while bids are being submitted or accepted (while tx are in mempool). Care has been taken to ensure that this is not possible (similar in theory to sandwich attacking but worse as if possible it could cause unexpected and non-consentual interest rate on a loan) and further-auditing of this is welcome.

However, there is little protection in place to protect the submitter of a bid from changes in market parameters.

Vulnerability Detail

In _submitBid(), certain bid parameters are taken from the marketRegistry:

1function _submitBid(...)
2 ...
3 (bid.terms.paymentCycle, bidPaymentCycleType[bidId]) = marketRegistry
4 .getPaymentCycle(_marketplaceId);
5
6 bid.terms.APR = _APR;
7
8 bidDefaultDuration[bidId] = marketRegistry.getPaymentDefaultDuration(
9 _marketplaceId
10 );
11
12 bidExpirationTime[bidId] = marketRegistry.getBidExpirationTime(
13 _marketplaceId
14 );
15
16 bid.paymentType = marketRegistry.getPaymentType(_marketplaceId);
17
18 bid.terms.paymentCycleAmount = V2Calculations
19 .calculatePaymentCycleAmount(
20 bid.paymentType,
21 bidPaymentCycleType[bidId],
22 _principal,
23 _duration,
24 bid.terms.paymentCycle,
25 _APR
26 );
27 ...

All the parameters taken from marketRegistry are controlled by the market owner: https://github.com/sherlock-audit/2023-03-teller/blob/main/teller-protocol-v2/packages/contracts/contracts/MarketRegistry.sol#L487-L511

Impact

If market parameters are changed in between the borrower submitting a bid transaction and the transaction being applied, borrower may be subject to changes in bidDefaultDuration, bidExpirationTime, paymentType, paymentCycle, bidPaymentCycleType and paymentCycleAmount.

That is, the user may be committed to the bid for longer / shorter than expected. They may have a longer / shorter default duration (time for the loan being considered defaulted / eligible for liquidation). They have un-provisioned for payment type and cycle parameters.

I believe most of this will have a medium impact on borrower (mild inconveniences / resolvable by directly repaying the loan) if the market owner is not evil and adapting the parameters reasonably.

An evil market owner can set the value of bidDefaultDuration and paymentCycle very low (0) so that the loan will default immediately. It can then accept the bid, make user default immediately, and liquidate the loan to steal the user's collateral. This results in a loss of collateral for the borrower.

Code Snippet

Tool used

Manual Review

Recommendation

Take every single parameters as input of _submitBid() (including fee percents) and compare them to the values in marketRegistry to make sure borrower agrees with them, revert if they differ.