Organization
- @centrifuge
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
Low Risk
8 findings
4 fixed
4 acknowledged
Informational
17 findings
15 fixed
2 acknowledged
Gas Optimizations
3 findings
2 fixed
1 acknowledged
Low Risk8 findings
BalanceSheet::multicall() function can lose ETH in edge case scenario
Severity
- Severity: Low
Submitted by
devtooligan
Description
The
multicall()
function implements batching functionality but lacks proper transaction payment handling that should accompany the batching operations. The function is marked aspayable
, indicating it can receive ETH, but it only handles batching start/end operations without corresponding payment operations.The vulnerability manifests in the edge case where:
- A user calls
multicall()
withmsg.value > 0
and an emptydata
array - The function starts batching, calls
super.multicall(data)
with no operations, and ends batching - No payment handling occurs, leaving the sent ETH permanently locked in the contract
While most individual functions in the contract are not payable and would revert if called with ETH, the empty data array scenario bypasses this protection since no function calls are attempted.
Note:
Hub::multicall()
andVaultRouter::multicall()
do have functionallity to processmsg.value
.Recommendation
Condider checking
msg.value == 0
or checkingdata.length >0
in theBalanceSheet::multicall()
function.Missing vault validation in link and unlink operations
State
Severity
- Severity: Low
Submitted by
devtooligan
Description
The
linkVault()
andunlinkVault()
functions in the Spoke contract access vault details directly without validating that the vault was properly deployed through the system. This inconsistency creates a potential avenue for linking unregistered vaults.In the
updateVault()
function, there is an explicit check to ensure only validated vaults are processed:// Needed as safeguard against non-validated vaults// I.e. we only accept vaults that have been deployed by the pool managerrequire(_vaultDetails[vault].asset != address(0), UnknownVault());
However, the
linkVault()
andunlinkVault()
functions directly access_vaultDetails[vault]
without this validation:function linkVault(PoolId poolId, ShareClassId scId, AssetId assetId, IVault vault) public auth { // ... other checks ... VaultDetails storage vaultDetails_ = _vaultDetails[vault]; // Missing validation require(!vaultDetails_.isLinked, AlreadyLinkedVault()); // ...}
This could allow administrators to link vaults that haven't been properly deployed through the
deployVault()
function, potentially leading to inconsistent system state.Recommendation
Consider moving the vault validity check inside
linkVault()
/unlinkVault()
.Reentrancy protection mechanism in _protected() is ineffective
State
- Acknowledged
Severity
- Severity: Low
Submitted by
devtooligan
Description
The Hub contract's reentrancy protection mechanism using the
_protected()
internal function does not provide actual protection against reentrancy attacks. This could leave functions vulnerable to reentrancy, potentially allowing attackers to manipulate contract state or drain funds.The Hub contract implements a
_protected()
internal function intended to prevent reentrancy:/// @dev Protect against reentrancyfunction _protected() internal protected {}
This function is used in critical operations like
notifyDeposit()
andnotifyRedeem()
with the expectation that it will prevent reentrancy attacks. However, this implementation is ineffective because:- The
protected
modifier's logic is not properly applied when used in this manner, because it is only on one location in the function. For nonreentrancy protection, these should be something at the beginning and the end of the function.
Functions relying on this protection include:
notifyDeposit()
- handles deposit notifications and callbacksnotifyRedeem()
- handles redemption notifications and callbacks
We don't see a real reentrancy possibility in these function, thus the risk is currently limited.
Note: a nonReentrancy mechanism is also important in relation to modifier
payTransaction
, because otherwise thetransactionRefund
can be overwritten.The function
_isManager()
uses a similar pattern as_protected()
and thus also doesn't protect against reentrancy. This is less important because its authorisation mechanism is effective.Proof of Concept
// SPDX-License-Identifier: MITpragma solidity ^0.8.28;import "hardhat/console.sol"; contract testReentrancy { error UnauthorizedSender(); address private transient _initiator; modifier protected() { if (_initiator == address(0)) { _initiator = msg.sender; _; _initiator = address(0); } else { require(msg.sender == _initiator, UnauthorizedSender()); _; } } function _protected() internal protected {} function notifyDeposit(uint n) public { console.log(n); _protected(); if (n > 0) this.notifyDeposit(n-1); } function test() public { notifyDeposit(5); }}
Recommendation
Use the
protected
modifier instead of the_protected()
function and remove the_protected()
function.For
_isManager()
: doublecheck if the intention of theprotected
modifier is to provide reentrancy protection. If so move if to the calling functions.Otherwise consider removing the
protected
modifier from_isManager()
because it doesn't add any value.Centrifuge
Will be solved in a future release.
Deployment address verification mismatch for contract addresses
Severity
- Severity: Low
Submitted by
devtooligan
Description
The
_verifyMainnetAddresses()
function inFullDeployer.s.sol
contains hardcoded address verification that does not match the actual deployed contract addresses documented in the official Centrifuge protocol deployments. Specifically, three contracts have mismatched addresses:asyncRequestManager
: Script expects0x58d57896EBbF000c293327ADf33689D0a7Fd3d9A
but documentation shows0xF06f89a1b6C601235729A689595571B7455dD433
asyncVaultFactory
: Script expects0xE01Ce2e604CCe985A06FA4F4bCD17f1F08417BF3
but documentation shows0xED9D489BB79c7cB58C522f36fC6944eaA95ce385
syncDepositVaultFactory
: Script expects0x3568184784E8ACCaacF51A7F710a3DE0144E4f29
but documentation shows0x21bf2544b5a0B03C8566a16592Ba1B3b192b50Bc
This discrepancy stems from version differences between v3.0.0 and v3.0.1 deployments, where the salt generation method changed from using hashed versions (
keccak256(abi.encodePacked("3"))
) to unhashed versions (bytes32(bytes("3"))
).Recommendation
Update the hardcoded addresses in
_verifyMainnetAddresses()
to match the correct v3.0.1 deployment addresses:function _verifyMainnetAddresses() internal view { // ... other addresses remain the same ...- require(address(asyncRequestManager) == 0x58d57896EBbF000c293327ADf33689D0a7Fd3d9A);+ require(address(asyncRequestManager) == 0xF06f89a1b6C601235729A689595571B7455dD433); require(address(syncManager) == 0x0D82d9fa76CFCd6F4cc59F053b2458665C6CE773);- require(address(asyncVaultFactory) == 0xE01Ce2e604CCe985A06FA4F4bCD17f1F08417BF3);+ require(address(asyncVaultFactory) == 0xED9D489BB79c7cB58C522f36fC6944eaA95ce385);- require(address(syncDepositVaultFactory) == 0x3568184784E8ACCaacF51A7F710a3DE0144E4f29);+ require(address(syncDepositVaultFactory) == 0x21bf2544b5a0B03C8566a16592Ba1B3b192b50Bc); // ... rest of addresses remain the same ...}
Also update the deployment documentation.
refund address not always checked to be non zero
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
In several locations a check is done that
address(subsidy[poolId].refund!=address(0)
. However in function_send()
this isn't explicitly checked.Recommendation
Consider using a general address if the refund address is zero. For example the
GLOBAL_POT
; although the requires additional administration. Alternatively remove rundunant zero checks.Centrifuge
Acknowledged. Right now from a system point of view, it's impossible to reach a state where refund is 0, because for sending some pool-related message, the pool has requires to be configured, which means a call to
setRefundAddress()
.From an unitary point of view of the Gateway, I think this check no longer makes sense because using address(0) or another undesired address has the same issue for the caller: they can not recover they funds.
The check in
subsidizePool
needs to stay there because anyone could subsidize an inexistent pool. Nevertheless, I think we can remove the current check in_requestPoolFunding
, given the above precondition.refund address and rely()
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Function
_requestPoolFunding()
can retrieve funds from any contract thatrely()
s on theGateway
contract.This can be done if that address is added via
setRefundAddress()
.The following contracts
rely()
s on theGateway
contract:- multiAdapter
- messageProcessor
The risk is limited because
setRefundAddress()
is authorized, andmultiAdapter
andmessageProcessor
don't inheritRecoverable
.A special case is the situation where
refund == address(this)
, thensubsidy[GLOBAL_POT].value
would be set to 0. This currently can't happen becauseGateway
doesn'trely()
on itself.Recommendation
Consider checking none of the system addresses are used in
setRefundAddress()
, especiallyaddress(this)
.Centrifuge
Acknowledged. This is prevented by the authorization mechanism.
poolId shouldn't be 0
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
poolId
shouldn't be 0, because that is a special value that is used forGLOBAL_POT
.There is no explicit checked in:
guardian::createPool()
hub::createPool()
hubRegistry.registerPool()
Note: there is an implicit check in
hub::createPool()
where it is checked withlocalCentrifugeId()
.Note: the risk is limited because the function setting the
poolId
are authorized.Recommendation
Consider explicitly checking
poolId != 0
.Centifuge
Acknowledged.
poolId
contains a non-zerocentrifugeId
, see findingCentrifugeId
shouldn't be 0.CentrifugeId shouldn't be 0
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
CentrifugeId
should not be 0 because this is a special value. It is also not allowed inMessageProcessor::handle()
.There is no explicit check that the
CentrifugeId
isn't0
, neither in the deployment script nor in the constructors ofMultiAdapter
orMessageDispatcher
.Recommendation
Consider explicitly checking
CentrifugeId != 0
to prevent configuration mistakes.Centrifuge
Acknowledged, this is handled by us as deployers.
Informational17 findings
Naming inconsistencies in OnOfframpManager
Severity
- Severity: Informational
Submitted by
devtooligan
Description
The
OnOfframpManager
constructor contains inconsistent variable naming that could lead to confusion for developers and auditors. The constructor parameter is namedspoke_
but is assigned to thecontractUpdater
state variable. This naming mismatch is inconsistent with theOnOfframpManagerFactory
, which correctly usescontractUpdater_
as the parameter name.Additionally, the error message
NotSpoke()
in theupdate
function is outdated and should use a more generic authorization error to match the naming convention used in similar contracts likeMerkleProofManager
.Recommendation
Update the constructor parameter name and error message for consistency.
Inconsistent License Identifier
Severity
- Severity: Informational
Submitted by
devtooligan
Description
Most interface files are changed to
GPL-2.0-or-later
in PR 477.However the following are not:
- ITokenFactory.sol
- IVaultFactory.sol
- IPoolEscrowFactory.sol
Recommendation
Review and update license info.
Simplify conditional structure
Severity
- Severity: Informational
Submitted by
devtooligan
Description
The
messageGasLimit()
function in the GasService contract uses an unnecessarily complex chain ofelse if
statements. Since each condition branch contains areturn
statement, theelse
keywords are redundant and can be removed to improve code readability and maintainability.The current implementation uses a long chain of
else if
statements:if (kind == MessageType.ScheduleUpgrade) { return scheduleUpgrade;} else if (kind == MessageType.CancelUpgrade) { return cancelUpgrade;} else if (kind == MessageType.RecoverTokens) { return recoverTokens;}// ... continues for all message types
Recommendation
Consider simplifying the conditional structure by removing the
else
keywords since each branch returns early:if (kind == MessageType.ScheduleUpgrade) return scheduleUpgrade;if (kind == MessageType.CancelUpgrade) return cancelUpgrade;if (kind == MessageType.RecoverTokens) return recoverTokens;if (kind == MessageType.RegisterAsset) return registerAsset;// ... apply same pattern to all remaining conditions
Missing TokenRecoverer contract registration
State
- Fixed
PR #559
Severity
- Severity: Informational
Submitted by
devtooligan
Description
The
TokenRecoverer
contract is deployed in the_preDeployCommon()
function but is not included in the contract registration block. While the contract is properly deployed and integrated into the system architecture, it is missing from the registration process that records deployed contract addresses as well as the deployment documentation.Recommendation
Add the TokenRecoverer contract to the registration block to ensure consistent documentation and tracking:
+ register("tokenRecoverer", address(tokenRecoverer));
Also add it to the deployment documentation.
Inconsistent setting of isIncrease when delta is zero
Severity
- Severity: Informational
Submitted by
devtooligan
Description
When net
deposits == 0
thenisIncrease
is set to True. This is inconsistent withsubmitQueuedShares
which usesshareQueue.isPositive
which currently is false when the delta is 0.Recommendation
Stay consistent by setting
isIncrease
to true whendeposits > 0
_requestPoolFunding() is suboptimal for shared refund addresses
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
If a
refund
address would be shared among different pools, then the approach of_requestPoolFunding()
is suboptimal, because it gets all funds fromrefund
and uses it to subsidizepoolId
.This would prevent other pools from using it, even though perhaps less funds are required.
Recommendation
Consider documenting at
setRefundAddress()
thatrefund
addresses should not be shared between pools.If sharing
refund
addresses would be relevant then consider retrieving only the required amount (eg.cost - subsidy[poolId].value
).uint96(...) truncates
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
uint96()
truncates the parameter, without error. This is very unlikely to cause issues though because such large amounts of native tokens won't occur.Recommendation
Consider using SafeCast.
Across chains different share tokens can have the same vault address
State
- Fixed
PR #560
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
As found by the project: right now the vault deployments are not using create2, but the factories themselves are deployed deterministically. This means the vault addresses are based on the nonce, which means that across chains, different share tokens can have the same vault address.
This can lead to user confusion.
Recommendation
Consider using
create2
and asalt
to deploy the vaults.Not all errors are custom errors
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Most errors use custom errors however, some errors use strings. This is inconsistent.
Recommendation
Consider using custom errors everywhere.
Different patterns for file()
State
- Fixed
PR #566
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Two different patterns are used for parameters of
file()
:wireWormholeAdapter
uses twicefile(..., centrifugeId, wormholeId, ...)
wireAxelarAdapter
usesfile(..., axelarId, centrifugeId, ...)
+file(.., centrifugeId, axelarId, ...)
The first pattern is easier because it only needs one
file()
function.Recommendation
Consider using the first pattern also in
wireAxelarAdapter
.Could use abi.encodeCall()
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Function
_safeGetAssetDecimals()
usedabi.encodeWithSignature()
. Howeverabi.encodeCall()
could also be used, which has additional checks.Recommendation
Consider using
abi.encodeCall()
.Typos in comments
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
There are some typos present.
Recommendation
Consider making the following changes:
- GasService: take -> taken
_verifyAdmin() check isn't foolproof
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The
_verifyAdmin()
check isn't foolproof. A vault contract could comply with these checks, while these signers would not be able to control the safe.This could be done in the following ways:
- have a 10 of 20 multisig where an attacker controls the other 12 accounts
- have a guard that prevents just signing by these addresses
- have module that allows alternative ways to allow transactions
- or a fake contract that always return true on a call to
isOwner()
See here for more info: https://ackee.xyz/blog/staying-safe-with-safe
Recommendation
Only use this check as a sanity check.
Centrifuge
Acknowledged. This is a check for us to double-check at deployment stage that the correct safe account is the configured one.
timestampedPath contains block.chainid twice
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
timestampedPath
containsblock.chainid
twice. According the rest of the string, the second instance should beblock.number
.Recommendation
Consider replacing the second instance of
block.chainid
withblock.number
.Extra safeguard for rely() and endorse()
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
In the deployment scripts, a large number of
rely()
statements are done. If accidentally an address is used for a contract that isn't deployed yet, then this isn't detected.Note: the same issue is present with
root::endorse()
.Recommendation
Consider having a wrapper function in the deployment scripts that check
user != address(0)
forrely()
andendorse()
.Alternatively this check can also be added in the functions themselves.
Centrifuge
Acknowledged. This is already checked in tests.
adminSafe not registered
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
CommonDeployer
has a comment thatregister("adminSafe"...)
isn't necessary.However
load_vars.sh
has been refactored intoload_config.py
, which doesn't do theregister()
.Recommendation
Consider adding
register("adminSafe"...)
.Centrifuge
We only register in
register()
contracts that we deploy to later be able to read them from the json. TheadminSafe
is already in the json (in another section). I've removed the comment which seems to no longer be correct and creates more confusion.Explicit type conversion
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Function
maxDeposit()
does an explicit type conversion fromuint128
touint256
, because_maxDeposit()
returns anuint128
.However the similar function
maxRedeem()
doesn't do this.Recommendation
Consider also adding an explicit type conversion in function
maxRedeem()
. Alternatively remove the explicit conversion inmaxDeposit()
.
Gas Optimizations3 findings
Gas savings by conditionally updating shareQueue
State
- Acknowledged
Severity
- Severity: Gas optimization
Submitted by
devtooligan
Description
Small gas savings available by conditionally updating
shareQueue
.Recommendation
Consider implementing something like:
- shareQueue.isPositive = shareQueue.delta != 0;+ if (shareQueue.delta == 0 && shareQueue.isPositive) shareQueue.isPositive = false;
Centrifuge
Acknowledged. We prefer to keep the current version that reduces ifs.
underpaid_[] entries stay forever
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
Once
repay()
succeeds, then theunderpaid_[]
isn't necessary any more. Keeping them increases the contract state.Recommendation
Consider deleting
underpaid[centrifugeId][batchHash]
whenunderpaid_.counter
reaches zero and the message is sucessfully send.Redundant check in newManager()
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
Function
newManager()
checks the token isn'taddress(0)
.However
Spoke::shareToken()
already checks thetoken !=0
, so this check is redundant.Recommendation
Consider removing the redundant check from
newManager()
.