Reports

Smart Contract Security Assessment

Centrifuge v3.1 Upgrade

Centrifuge v3 implements a decentralized protocol for on-chain asset management. It provides foundations for permissionless deployment and management of highly customizable tokenization solutions.

10
Issues
1
C/H/M
Period
Oct 06, 2025 - Oct 14, 2025
Auditors
adriro, watermelon

Review Summary

Protocol Overview

Centrifuge v3 implements a decentralized protocol for on-chain asset management. It provides foundations for permissionless deployment and management of highly customizable tokenization solutions.

Protocol
Centrifuge
Timeline
Oct 06, 2025 - Oct 14, 2025
Audit Team
adriro, watermelon

Audit Overview

Scope and Resources

Scope

This audit covers the v3.1 upgrade, which comprises changes to previously reviewed contracts and entirely new ones, across 7 days of review.

Overall Assessment

Centrifuge's v3.1 represents a mature, incrementally evolving protocol with a keen focus on its security stance. The protocol demonstrates strong foundations and a commitment to being secured through continuous auditing, conservative refactoring practices, and controlled feature deployment that prioritizes code quality and readability alongside functionality.

Evaluation Matrix

access control

mathematics

complexity

libraries

decentralization

code stability

documentation

monitoring

testing

Key Findings

Findings Summary

0
Critical
0
High
1
Medium
4
Low
4
Informational
1
Gas
M-1 Finding

M-1: Prevent overflow in NAV accounting

Medium

Description:

The calculation of the NAV is given by the netAssetValue() function.

213:         return equity + gain - loss - liability;

If the liabilities are eventually greater than the adjusted equity, the calculation would overflow since these are unsigned integers. This can cause a revert in the path that submits snapshots to the Hub, which are hooked to the NAVManager.

Impact:

Medium. The issue can block updates to the Hub for as long as the calculation overflows.

Recommendation:

Consider clamping the calculation to zero to avoid the revert.

Note that this could also skew the final NAV after being aggregated in the SimplePriceManager contract.

Developer Response:

Fixed by PR#708.

L-1 Finding

L-1: The `approveRedeems()` function can be non-payable

Low

Description:

The approveRedeems() function doesn't need to be payable since it doesn't dispatch a message.

Impact:

Low.

Recommendation:

Remove the payable modifier.

Developer Response:

Fixed in PR#721.

L-2 Finding

L-2: `maxRedeemClaims()` should depend on the last `redeem` epoch

Low

Description:

The refactored maxRedeemClaims() function in BatchRequestManager uses the revoke field from the stored EpochId struct, but it should take the redeem value instead.

Impact:

Low.

Recommendation:

Change the revoke field for the redeem field.

Developer Response:

Fixed by PR#720 which actually changes maxDepositClaims to use epochId[..].issue instead of *.deposit because otherwise maxDepositClaims returns 1 claimable epoch after approveDeposits while notifyDeposit fails with IssuanceRequired as long as the issuance is missing (i.e. epochId.issue < epochId.deposit). So before this fix, it incorrectly signals a possible deposit claim.

L-3 Finding

L-3: `SyncManager.maxDeposit()` uses incorrect denomination in `_canTransfer()` call

Low

Summary:

PR#675 introduces changes for SyncManager.maxMint() and SyncManager.maxDeposit() functions to take into account potential transfer restrictions imposed by a vault's share token's hooks.

Description:

The current SyncManager.maxDeposit() implementation passes the result of SyncManager._maxDeposit, which is an amount denominated in a vault's asset, as a parameter in a call to SyncManager._canTransfer() which expects an amount of vault shares.

Impact:

Low. While current ITransferHook implementations do not impose transfer restrictions based on the amount of share tokens being transferred, passing an asset amount instead of a share amount may hinder correct functionality in future implementations.

Recommendation:

Calculate the amount of corresponding shares using convertToShares(vault_, amount) as is done within SyncManager.maxMint().

Developer Response:

Fixed by PR#723.

L-4 Finding

L-4: Check accounts are positive in `closeGainLoss()`

Low

Description:

The implementation of closeGainLoss() ignores the isPositive return value when fetching the current state of the gain and loss accounts.

176:         (, uint128 gainValue) = accounting.accountValue(poolId, gainAccount_);
177:         (, uint128 lossValue) = accounting.accountValue(poolId, lossAccount_);

The function then proceeds to adjust the equity account based on these values. If there are gains, it debits the gain account and credits the equity. If there are losses, it credits the loss account (debit normal) and debits the equity.

Impact:

Low. The accounting would be incorrect if the returned values are not positive.

Recommendation:

Given the implementation assumptions, consider checking that the returned values are indeed positive.

Developer Response:

Fixed in PR#728.

I-1 Finding

I-1: `BaseTransferHook.isDepositRequestOrIssuance()` returns `true` for mints to `crosschainSource`

Informational

Summary:

BaseTransferHook.isDepositRequestOrIssuance() may be used within a ITransferHook implementation to capture and execute arbitrary logic when vault share tokens are minted to an address different than BaseTransferHook.depositTarget.

Description:

During a cross chain transfer's delivery on the target chain, share tokens are first minted into the Spoke contract and then transferred to the recipient: link.

Given that the Spoke contract is assigned to BaseTransferHook.crosschainSource, which is know to be different BaseTransferHook.depositTarget, the mentioned predicate will return true during the share token mint.

Impact:

Informational.

Recommendation:

Modify the mentioned function to not return true when to == crosschainSource:

 @@ -109,23 +109,29 @@ abstract contract BaseTransferHook is Auth, IMemberlist, IFreezable, ITransferHo
     ) public view virtual returns (bool);
 
     function isDepositRequestOrIssuance(address from, address to) public view returns (bool) {
-        return from == address(0) && to != depositTarget;
+        return from == address(0) && to != depositTarget && to != crosschainSource;
     }

Developer Response:

Fixed in PR#725.

I-2 Finding

I-2: `BaseTransferHook.trustedCall()` ignores inner kind switch

Informational

Description:

The implementation of trustedCall() in BaseTransferHook doesn't switch on the kind field of the UpdateContractUpdateAddress struct, treating all messages as updates to the manager.

Impact:

Informational.

Recommendation:

Check if kind equals some constant and revert if not.

UpdateContractMessageLib.UpdateContractUpdateAddress memory m =
    UpdateContractMessageLib.deserializeUpdateContractUpdateAddress(payload);
    
if (m.kind == "manager") {
    address token = address(spoke.shareToken(poolId, scId));
    require(token != address(0), ShareTokenDoesNotExist());

    manager[token][m.what.toAddress()] = m.isEnabled;
} else {
    revert UnknownUpdateContractKind();
}

Developer Response:

Fixed in PR#715.

I-3 Finding

I-3: Incorrect Nastspec documentation in `OracleValuation`

Informational

Summary:

OracleValuation.sol provides an implementation for trusted price oracles to update asset prices.

Description:

The comments at OracleValuation.sol#L20-L22 indicate that, to utilize the highlighted contract developers must use hub.updateFeeder(). While Hub.sol doesn't implement such method, the correct way to integrate such contract is by using Holdings.updateValuation.

Impact:

Informational.

Recommendation:

Correct the documentation as shown.

Developer Response:

Fixed in PR#725.

I-4 Finding

I-4: `HubRegistry.updateCurrency()` allows assiging non-regsitered currency to an existing pool

Informational

Summary:

HubRegistry.updateCurrency may be used by an authorized address to update the HubRegistry.currency mapping.

Description:

The method fails to ensure that the new currency_ being linked to a given poolId_ has been previously registered via HubRegistry.registerAsset.

Impact:

Informational.

Recommendation:

Ensure currency_ has been registered:

@@ -87,6 +89,7 @@ contract HubRegistry is Auth, IHubRegistry {
     function updateCurrency(PoolId poolId_, AssetId currency_) external auth {
         require(exists(poolId_), NonExistingPool(poolId_));
         require(!currency_.isNull(), EmptyCurrency());
+        require(isRegistered(currency_));
 
         currency[poolId_] = currency_;

Developer Response:

Fixed as recommended in PR#724.

G-1 Finding

G-1: Gas savings in QueueManager

Gas

Description:

The expression to check if the delay has elapsed can be simplified to the third sub-expression.

74:         require(
75:             sc.lastSync == 0 || sc.minDelay == 0 || block.timestamp >= sc.lastSync + sc.minDelay, MinDelayNotElapsed()
76:         );

For the duplicate check in the assetIds array, the scId argument can be removed, as this is constant for all elements. The validation can be simplified by ensuring asset IDs are in ascending order instead of using temporal storage. The check can also be removed because calling submitQueuedAssets() would blank deposits and withdrawals, causing the same asset ID to be skipped if repeated.

80:         for (uint256 i = 0; i < assetIds.length; i++) {
81:             bytes32 key = keccak256(abi.encode(scId.raw(), assetIds[i].raw()));
82:             if (TransientStorageLib.tloadBool(key)) continue; // Skip duplicate
83:             TransientStorageLib.tstore(key, true);
84: 
85:             // Check if valid
86:             (uint128 deposits, uint128 withdrawals) = balanceSheet.queuedAssets(poolId, scId, assetIds[i]);
87:             if (deposits > 0 || withdrawals > 0) {
88:                 balanceSheet.submitQueuedAssets(poolId, scId, assetIds[i], sc.extraGasLimit, address(0));
89:                 validCount++;
90:             }
91:         }

Additionally, the call to queuedShares() can be moved below the assets loop to fetch the updated value of queuedAssetCounter instead of tracking the validCount and then comparing it against the cached value of queuedAssetCounter.

Impact:

Gas savings.

Recommendation:

Consider implementing the recommended suggestions.

Developer Response:

Fixed in PR#726.

Final Remarks

The audited focused on both incremental updates to core and hook related contracts from version 3.0.1, along with new contracts introduced with version 3.1. Updates to pre-existing contracts imply no major change in the system's business logic, rather they pose a refactor to significantly improve the codebases's readability and simplicity. Within the refactor, one minor issue was identified causing an incorrect epoch to be used within calculations for the maximum claimable redeem epoch for a given investor. The contracts introduced to the system in version 3.1 intend to implement pool net asset value (NAV) calculations fully on-chain, via a new `IValuation` implementation which accepts asset price submissions from authorized accounts and two new `ISnapshotHook` implementations. Within these contracts, one medium severity issue was identified: net asset value calculations could trigger and uncaught negative overflow, which would result in failures within transactions that would submit snapshot updates to the Hub.

Methodology

Severity Classification

Critical

Immediate threat to user funds or protocol integrity

Direct loss of funds, protocol compromise

High

Significant security risk requiring urgent attention

Potential fund loss, major functionality disruption

Medium

Important issue that should be addressed

Limited fund risk, functionality concerns

Low

Minor issue with minimal impact

Best practice violations, minor inefficiencies

Gas

Findings that improve gas efficiency

Increased transaction costs

Informational

Code quality and best practice recommendations

Reduced maintainability and readability