Reports

Smart Contract Security Assessment

Centrifuge v3.1 Fix Review

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

7
Issues
0
C/H/M
Period
Not specified - Jan 18, 2026
Auditors
HHK, Watermelon

Review Summary

Protocol Overview

Centrifuge v3.1 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
Not specified - Jan 18, 2026
Audit Team
HHK, Watermelon

Scope

This audit covers 20 pull requests across 4 engineering days. The client's pull requests were reviewed in two separate batches: the first 16 pull requests were reviewed between December 3rd, 2025 and December 5th, 2025. The remaining 4 pull requests were submitted at a later date by the client and reviewed by the yAudit team on January 16th, 2026.

Overall Assessment

Centrifgue v3.1 represents a mature codebase with strong focus on its security stance. The fixes implemented to address findings identified in Centrifuge v3.1's audit competition were found to be correctly implemented, with researchers identifying only minor gas optimizations and styling recommendations to apply a final layer of polish to the codebase.

Evaluation Matrix

access control

mathematics

complexity

libraries

decentralization

code stability

documentation

monitoring

testing

Key Findings

Findings Summary

0
Critical
0
High
0
Medium
0
Low
5
Informational
2
Gas
I-1 Finding

I-1: Missing event emission in `BatchRequestManager.setEpochIds`

Informational

Description:

BatchRequestManager.setEpochIds implements functionality for a BatchRequestManager ward to override values in epochId mapping during a migration from V3's ShareClassManager to V3.1's BatchRequestManager.

The method could benefit from an event emission in order for off-chain components to be able to track calls to the highlighted method.

Impact:

Informational.

Recommendation:

Define an EpochIdModified(PoolId pool) event and log it within the highlighted method:

@@ -94,9 +94,12 @@ contract BatchRequestManager is Auth, BatchedMulticall, IBatchRequestManager {
         _;
     }
 
+    event EpochIdModified(PoolId poolId, ShareClassId scId, AssetId assetId, EpochId epochIdData);
+
     /// @dev used only for migrations
     function setEpochIds(PoolId poolId, ShareClassId scId, AssetId assetId, EpochId memory epochIdData) external auth {
         epochId[poolId][scId][assetId] = epochIdData;
+        emit EpochIdModified(poolId, scId, assetId, epochIdData);
     }

Developer Response:

Fixed by 0eb5d40

I-2 Finding

I-2: Missing nastpec documentation for `extraGasLimit` param in `ISpoke.request`

Informational

Description:

ISpoke.request now accepts an extraGasLimit parameter in order for callers to provide an additional gas stipend for cross-chain execution.

The highlighted method's natspec documentation is missing a description for the extraGasLimit parameter.

Impact:

Informational.

Recommendation:

Add a description for the extraGasLimit to the highlighted method's documentation.

Developer Response:

Fixed by 740efd3.

I-3 Finding

I-3: Missing event inside `SubsidyManager`

Informational

Description:

The SubsidyManager contract emits a WithdrawSubsidy event inside withdraw() but not inside withdrawAll().

Since both functions have similar behavior, with the main difference being that withdrawAll() withdraws the entire available balance, it seems expected that it would also emit the WithdrawSubsidy event.

Impact:

Informational.

Recommendation:

Emit WithdrawSubsidy event inside withdrawAll().

function withdrawAll(PoolId poolId, address to) external auth returns (address, uint256) {
...
   refund.withdrawFunds(to, amount);
+  emit WithdrawSubsidy(poolId, to, amount);

   return (address(refund), amount);
}

Developer Response:

Fixed by 7934e82.

I-4 Finding

I-4: Outdated README references global escrow

Informational

Description:

The vault README.md still mentions global escrow even though it has been replaced with pool-specific escrow, which can be confusing for future auditors and integrators:

  • "The vault enforces controller/owner validation for all operations and integrates with the global escrow for asset custody"
  • "coordinating with BalanceSheet for share issuance/burning and the global escrow for asset custody."
  • Also still present in some diagrams

Impact:

Informational.

Recommendation:

Update the README and diagrams to reference pool-specific escrow changes made.

Developer Response:

Fixed by bd06c52.

I-5 Finding

I-5: Unused named return parameter in `AsyncRequestManager.maxMint`

Informational

Description:

AsyncRequestManager.maxMint defines the shares named return parameter but fails to assign a value to it.

Impact:

Informational.

Recommendation:

Maintain consistency with the rest of the max* methods and assign the return value to shares:

@@ -554,7 +554,7 @@ contract AsyncRequestManager is Auth, IAsyncRequestManager, ITrustedContractUpda
         if (!_canTransfer(
                 vault_, address(balanceSheet.escrow(vault_.poolId())), user, uint256(investments[vault_][user].maxMint)
             )) return 0;
-        return uint256(investments[vault_][user].maxMint);
+        shares = uint256(investments[vault_][user].maxMint);
     }
 
     /// @inheritdoc IRedeemManager

Developer Response:

Fixed by 2706805.

G-1 Finding

G-1: Salt reuse check may be executed earlier to save gas in revert case

Gas

Description:

ShareClassManager.addShareClass now requires the salt parameter's first 8 bytes to match the provided poolId value, in order to prevent salt values previously used for a pool from being used for a different pool.

The method also ensures that a given salt is used only once, by storing used values in its ShareClassManager.salts mapping and checking a new salt against such set: if the salt has already been used, a AlreadyUsedSalt error is returned.

Both checks may be moved to the beginning of the method, in order to marginally reduce the gas consumed by the method in both unhappy paths.

Impact:

Gas optimization.

Recommendation:

Move the code related to the mentioned checks to the beginning of the method:

@@ -38,6 +38,10 @@ contract ShareClassManager is Auth, IShareClassManager {
         auth
         returns (ShareClassId scId_)
     {
+        PoolId prefixedPoolId = PoolId.wrap(uint64(bytes8(salt)));
+        require(poolId == prefixedPoolId, InvalidSalt());
+        require(!salts[salt], AlreadyUsedSalt());
+
         scId_ = previewNextShareClassId(poolId);
 
         uint32 index = ++shareClassCount[poolId];
@@ -45,9 +49,6 @@ contract ShareClassManager is Auth, IShareClassManager {
 
         ShareClassMetadata storage meta = _updateMetadata(poolId, scId_, name, symbol);
 
-        PoolId prefixedPoolId = PoolId.wrap(uint64(bytes8(salt)));
-        require(poolId == prefixedPoolId, InvalidSalt());
-        require(!salts[salt], AlreadyUsedSalt());
         salts[salt] = true;
         meta.salt = salt;

Developer Response:

Fixed by 9eb2a2d.

G-2 Finding

G-2: Cache `reservedBy` mapping to avoid duplicate storage reads

Gas

Description:

The reserve() and unreserve() functions load the reservedBy mapping twice when they could load it once by declaring newReservedAmount earlier in the function.

Impact:

Gas savings.

Recommendation:

Declare newReservedAmount earlier and use it instead of incrementing.

function reserve(...) external auth {
+   uint128 newReservedAmount = reservedBy[scId][caller][reason][asset][tokenId] + value;
-   reservedBy[scId][caller][reason][asset][tokenId] += value;
+   reservedBy[scId][caller][reason][asset][tokenId] = newReservedAmount;
    holding_.reserved += value;

-   uint128 newReservedAmount = reservedBy[scId][caller][reason][asset][tokenId];
    emit IncreaseReserve(asset, tokenId, poolId, scId, caller, reason, value, newReservedAmount);
}
function unreserve(...) external auth {
+   uint128 newReservedAmount = reservedBy[scId][caller][reason][asset][tokenId] - value;
-   reservedBy[scId][caller][reason][asset][tokenId] -= value;
+   reservedBy[scId][caller][reason][asset][tokenId] = newReservedAmount;
    holding_.reserved -= value;

-   uint128 newReservedAmount = reservedBy[scId][caller][reason][asset][tokenId];
    emit DecreaseReserve(asset, tokenId, poolId, scId, caller, reason, value, newReservedAmount);
}

Developer Response:

Fixed by 85efcfc.

Final Remarks

The reviewed pull requests were found to correctly address the related issues identified within the latest audit competition held by the Centrifuge team, uncovering a small set of issues related with gas consumption optimization and code readability.

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