Sablier

Sablier
DeFiFoundry
53,440 USDC
View results
Submission Details
Severity: low
Invalid

Malicious users can create streams without paying broker fee

Summary

Malicious users can create linear/dynamic/tranched streams without paying broker fee in some tokens.

Vulnerability Details

When protocol create linear/dynamic/tranched streams internally call Helpers#checkAndCalculateBrokerFee function:

function checkAndCalculateBrokerFee(
uint128 totalAmount,
UD60x18 brokerFee,
UD60x18 maxBrokerFee
) {
...
// @audit-issue -- here `amounts.brokerFee` can be 0 if `ud(totalAmount).mul(brokerFee)` become 0.
amounts.brokerFee = uint128(ud(totalAmount).mul(brokerFee).intoUint256());
assert(totalAmount > amounts.brokerFee);
...
// Calculate the deposit amount (the amount to stream, net of the broker fee).
amounts.deposit = totalAmount - amounts.brokerFee;
...
}

As we can see, the protocol firstly calculate brokerFee based on totalAmount and brokerFee, then subtract the brokerFee from totalAmount and calculate the deposit amount, it use ud(totalAmount).mul(brokerFee) to calculate the brokerFee amount, which is a function from prb library.

After test, it's possible that ud(totalAmount).mul(brokerFee) would become 0 if totalAmount * brokerFee less than 1, then solidity round down the result to 0, such that brokerFee would be 0 and broker will not receive any broker fee.

From contests compatibilities description and dapp site we can know, the protocol support any standard ERC20 token and also deployed on polygon. When underlying token‘s
decimal is small like 2, such as polygon EURO token decimal is 2, the malicious users can batch create streams with small amounts without paying broker fee.

note: EIP-20 don't specify token decimals must be 18, it could be 2, 6 and 8.

We use polygon euro token as an example:

diff --git a/v2-core/test/integration/fuzz/lockup-linear/createWithDurations.t.sol b/v2-core/test/integration/fuzz/lockup-linear/createWithDurations.t.sol
index 9fa96dd..7121de3 100644
--- a/v2-core/test/integration/fuzz/lockup-linear/createWithDurations.t.sol
+++ b/v2-core/test/integration/fuzz/lockup-linear/createWithDurations.t.sol
@@ -2,11 +2,17 @@
pragma solidity >=0.8.22 <0.9.0;
import { Errors } from "src/libraries/Errors.sol";
-import { Lockup, LockupLinear } from "src/types/DataTypes.sol";
+import { Lockup, LockupLinear, Broker } from "src/types/DataTypes.sol";
import { CreateWithDurations_Integration_Shared_Test } from "../../shared/lockup/createWithDurations.t.sol";
import { LockupLinear_Integration_Fuzz_Test } from "./LockupLinear.t.sol";
+import { ud60x18 } from "@prb/math/src/UD60x18.sol";
+
+import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
+import { console2 } from "forge-std/src/console2.sol";
+
+
contract CreateWithDurations_LockupLinear_Integration_Fuzz_Test is
LockupLinear_Integration_Fuzz_Test,
CreateWithDurations_Integration_Shared_Test
@@ -114,4 +120,55 @@ contract CreateWithDurations_LockupLinear_Integration_Fuzz_Test is
address expectedNFTOwner = users.recipient;
assertEq(actualNFTOwner, expectedNFTOwner, "NFT owner");
}
+
+ // @audit-test
+ function testBrokerFeeWhenUseEURS_RoundIssue() public {
+ LockupLinear.Durations memory durations = LockupLinear.Durations({
+ cliff: 0,
+ total: 1 seconds
+ });
+
+ users.broker = createUser("Broker");
+
+ ERC20EursMock eurs = new ERC20EursMock("EURS", "eurs");
+ assertEq(eurs.decimals(), 2);
+ address broker = address(0x123);
+ uint128 amount = 333;
+ eurs.approve(address(lockupLinear), type(uint256).max);
+ LockupLinear.CreateWithDurations memory params = LockupLinear.CreateWithDurations({
+ sender: users.sender,
+ recipient: users.recipient,
+ totalAmount: amount,
+ asset: eurs,
+ cancelable: true,
+ transferable: true,
+ durations: durations,
+ broker: Broker(broker, ud60x18(0.003e18))
+ });
+
+ uint256 gas_bef = gasleft();
+ uint256 loop_time = 250;
+ for (uint256 i = 0; i < loop_time; i++) {
+ lockupLinear.createWithDurations(params);
+ }
+
+ uint256 gas_cost = gas_bef - gasleft();
+ assertEq(eurs.balanceOf(broker), 0);
+ assertEq(eurs.balanceOf(address(lockupLinear)), amount * loop_time);
+ // polygon gas limit: 30,000,000
+ assertLt(gas_cost, 30_000_000);
+ console2.log("lockupLinear eur token bal: ", eurs.balanceOf(address(lockupLinear))/10**2);
+ console2.log("gas cost: ", gas_cost);
+ }
+}
+
+
+contract ERC20EursMock is ERC20 {
+ constructor(string memory name, string memory symbol) ERC20(name, symbol) {
+ _mint(msg.sender, 100_000_000);
+ }
+
+ function decimals() public view virtual override returns (uint8) {
+ return 2;
+ }
}

Execute git patch to patch the diff directly.

Result:

lockupLinear eur token bal: 832
gas cost: 28843555

As we can see, when polygon EURO token as underlying token with totalAmount = 333, broker = 0.3%, 333 * 0.003 = 0.999 then round down to 0 by solidity, malicious users can create 250 times LockupLinear streams in one tx without paying broker fee, the gas cost is 28843555 less then polygon block limit 30,000,000, the free created stream tokens value is 832$ euro.

Impact

Malicious users can create linear/dynamic/tranched streams with small amounts without paying broker fee if underlying tokens decimal is small.

Tools Used

vscode, Manual Review

Recommendations

Calculate the amounts.deposit by totalAmount * (1 - brokerFee) first, then calculate the amounts.brokerFee by totalAmount - amounts.deposit to make sure the amounts.brokerFee never become 0. This issue is similar to the-graph-rounding-error-bug, so the mitigation can be same.

Updates

Lead Judging Commences

inallhonesty Lead Judge
over 1 year ago
inallhonesty Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Info/Gas/Invalid as per Docs

https://docs.codehawks.com/hawks-auditors/how-to-determine-a-finding-validity

pks271 Submitter
over 1 year ago
pks271 Submitter
over 1 year ago
inallhonesty Lead Judge
over 1 year ago
pks271 Submitter
over 1 year ago
inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Info/Gas/Invalid as per Docs

https://docs.codehawks.com/hawks-auditors/how-to-determine-a-finding-validity

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.