Reports

Smart Contract Security Assessment

Centrifuge v3.0.1 Update

Centrifuge V3 is an open, decentralized protocol for on-chain asset management. Built on immutable smart contracts, it enables permissionless deployment of customizable tokenization products.

13
Issues
1
C/H/M
Period
Aug 04, 2025 - Aug 08, 2025
Auditors
ret2basic, iampukar, adriro

Review Summary

Protocol Overview

Centrifuge V3 is an open, decentralized protocol for on-chain asset management. Built on immutable smart contracts, it enables permissionless deployment of customizable tokenization products.

Protocol
Centrifuge
Timeline
Aug 04, 2025 - Aug 08, 2025
Audit Team
ret2basic, iampukar, adriro

Audit Overview

Scope and Resources

Scope

This audit covers the v3.0.1 update to the previously audited files in scope of the report performed during July 2025.

Overall Assessment

The codebase demonstrates solid architectural foundations with a focus on deterministic deployments and cross-chain compatibility through the use of CREATE3 deployment patterns and established bridge integrations.

Evaluation Matrix

access control

mathematics

complexity

libraries

decentralization

code stability

documentation

monitoring

testing

Key Findings

Findings Summary

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

M-1: Permissioned deployment protection is disabled in create3 calls

Medium

Summary:

In the deploy scripts, salt generation is based on the contract name and version number. The underlying create3 lib, CreateX, only enables permissioned deployment protection when the first 20 bytes in the salt are equal to msg.sender. In other words, currently, all create3 calls will be executed without this protection.

The security concern is that, since create3 deployment is deterministic, an attacker can frontrun Centrifuge’s official deployment on new chains and occupy that address. If this happens, the “contract address stays the same across all chains” assumption will be broken.

Description:

In Centrifuge deploy scripts, all contracts are deployed using the create3() function. This is essentially a wrapper around pcaversaccio's CreateX project; the function being called is deployCreate3().

Across all the chains, CreateX is expected to be deployed at 0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed. The entire deploy chain is:

  1. User calls createx-forge (the wrapper) create3() function
  2. The wrapper interacts with CreateX deployed at 0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed
  3. CreateX deployCreate3() function deploys a proxy contract using CREATE2 opcode. The factor that determines where the proxy is deployed is guardedSalt; the logic is implemented in an internal function _guard()
  4. The proxy bytecode embeds CREATE opcode in it. CreateX sends the initCode of the actual contract being deployed to this proxy and lets the proxy deploy it. In other words, the proxy is the deployer, so the predicted address of the actual contract depends on the address of the proxy (the deployer).

How the salt should be formatted is defined in _parseSalt(). In particular, we care about the address(bytes20(salt)) == msg.sender check: this check must pass so that the permissioned deployment protection will be enabled. In other words, the first 20 bytes of salt must be msg.sender.

Currently, the deploy scripts are using salt generated from the contract name and version, so the permissioned deployment protection is disabled.

Impact:

Medium. When Centrifuge expands to new chains using this deployment script, an attacker could frontrun the transaction with identical data to occupy the expected addresses. While highly unlikely, current deployments may also have been vulnerable to such attacks, though any successful frontrunning attempt should have triggered a revert in the deployment script.

Recommendation:

Set the first 20 bytes of each salt to msg.sender to turn on CreateX built-in permissioned deployment protection.

Developer Response:

The Centrifuge team has verified that all deployed addresses on mainnet were deployed by our main deployment wallet and have not been frontrun, and all parameters have been tested using fork tests to ensure they are the intended parameters.

Fixed in PR 573.

I-1 Finding

I-1: `TokenRecoverer` is deployed but never added to the JSON registry

Informational

Summary:

CommonDeployer._preDeployCommon() instantiates a TokenRecoverer contract and passes its address to both MessageProcessor and MessageDispatcher.

Unlike every other contract deployed in this phase, TokenRecoverer is never registered via register("tokenRecoverer", …), so its address is omitted from the generated deployment JSON.

Description:

// 1. Deployment (correct)
tokenRecoverer = TokenRecoverer(
    create3(
        generateSalt("tokenRecoverer"),
        abi.encodePacked(type(TokenRecoverer).creationCode, abi.encode(root, batcher))
    )
);

// 2. Down-stream dependencies (correct)
messageProcessor = MessageProcessor(
    create3(
        generateSalt("messageProcessor"),
        abi.encodePacked(type(MessageProcessor).creationCode, abi.encode(root, tokenRecoverer, batcher))
    )
);

messageDispatcher = MessageDispatcher(
    create3(
        generateSalt("messageDispatcher"),
        abi.encodePacked(
            type(MessageDispatcher).creationCode,
            abi.encode(input.centrifugeId, root, gateway, tokenRecoverer, batcher)
        )
    )
);

// 3. Registration list (incomplete)
register("root", address(root));
register("guardian", address(guardian));
register("gasService", address(gasService));
register("gateway", address(gateway));
register("multiAdapter", address(multiAdapter));
register("messageProcessor", address(messageProcessor));
register("messageDispatcher", address(messageDispatcher));
register("poolEscrowFactory", address(poolEscrowFactory));

// register tokenRecoverer call is missing

Because, no call to register("tokenRecoverer", address(tokenRecoverer)) is ever made, the JSON file produced by JsonRegistry.saveDeploymentOutput() has entries for every other deployed contract except TokenRecoverer.

Impact:

Informational. While the deployment itself succeeds and the on-chain state is correct, the missing registry entry means that any automated process relying on the env/latest/*.json mapping to wire together components, particularly recovery scripts, will not be able to determine the TokenRecoverer address without manual intervention.

Recommendation:

Register the TokenRecoverer contract immediately after deployment, keeping the logical ordering used for other entries:

register("tokenRecoverer", address(tokenRecoverer));

Developer Response:

Fixed in PR 559.

I-2 Finding

I-2: Inadequate `extraGasLimit` for `submitQueuedAssets` Calls in Test Script

Informational

Summary:

The TestData script calls BalanceSheet.submitQueuedAssets() (and submitQueuedShares()) with an extraGasLimit of zero.

Suppose the underlying BalanceSheet implementation relies on that parameter to top-up the per-message gas forwarded to downstream contracts. In that case, the call can revert on L2s / side-chains that enforce minimum gas pre-payment (e.g., Optimism) or on chains that embed gas-refund logic (e.g., Celo).

A non-zero extra gas allowance should be provided by default (≈ 2_000_000) to ensure reliability across networks.

Description:

uint128 constant DEFAULT_EXTRA_GAS = uint128(0);

hub.approveDeposits(...);
balanceSheet.submitQueuedAssets(poolId, scId, assetId, DEFAULT_EXTRA_GAS);

balanceSheet.withdraw(...);
balanceSheet.submitQueuedAssets(poolId, scId, assetId, DEFAULT_EXTRA_GAS);

hub.issueShares(...);
balanceSheet.submitQueuedShares(poolId, scId, DEFAULT_EXTRA_GAS);

The helper constant is always 0, so every queue-flushing step forwards no head-room gas. Most EVM chains tolerate this, but chains that internally re-fund gas (or require a safety margin for meta-transactions) often revert when the callee cannot draw additional gas from a zero allowance.

Impact:

Informational. Test deployments on certain EVM networks (notably Celo or Optimism) may fail when submitQueuedAssets or submitQueuedShares reverts due to an under-funded extraGasLimit. This only affects testing or scripts, not core protocol logic.

Recommendation:

Increase DEFAULT_EXTRA_GAS to a non-zero value (e.g., 2_000_000) that is sufficient to cover cross-chain callback execution on target networks.

- uint128 constant DEFAULT_EXTRA_GAS = uint128(0);
+ uint128 constant DEFAULT_EXTRA_GAS = uint128(2_000_000);

Developer Response:

Fixed in ddd94da.

I-3 Finding

I-3: Typo in control flag `shouldLaberAddresses` inside `JsonRegistry`

Informational

Summary:

The deployment-helper contract JsonRegistry stores a boolean flag named shouldLaberAddresses. Because “Laber” is a misspelling of “Label”, the variable name is misleading. The typo is repeated in the setter and in the single read site, so the code compiles and behaves correctly, but readability suffers.

Description:

contract JsonRegistry is Script {
    string deploymentOutput;
    uint256 registeredContracts = 0;
    bool shouldLaberAddresses;             // ← misspelled
    string addressLabelPrefix;

    function register(string memory name, address target) public {
        deploymentOutput = (registeredContracts == 0)
            ? string(abi.encodePacked(deploymentOutput, '    "', name, '": "', vm.toString(target), '"'))
            : string(abi.encodePacked(deploymentOutput, ',\n    "', name, '": "', vm.toString(target), '"'));

        registeredContracts += 1;

        if (shouldLaberAddresses) {          // ← misspelled
            vm.label(address(target), string(abi.encodePacked(addressLabelPrefix, name)));
        }
    }

    function labelAddresses(string memory prefix) public {
        shouldLaberAddresses = true;       // ← misspelled
        addressLabelPrefix = prefix;
    }
    ....
}

The intended meaning is “Should Label Addresses”.

Impact:

Informational.

Recommendation:

Rename the variable and all references from shouldLaberAddresses to shouldLabelAddresses.

contract JsonRegistry is Script {
    string deploymentOutput;
    uint256 registeredContracts = 0;
-   bool shouldLaberAddresses;             
+   bool shouldLabelAddresses;             
    string addressLabelPrefix;

    function register(string memory name, address target) public {
        deploymentOutput = (registeredContracts == 0)
            ? string(abi.encodePacked(deploymentOutput, '    "', name, '": "', vm.toString(target), '"'))
            : string(abi.encodePacked(deploymentOutput, ',\n    "', name, '": "', vm.toString(target), '"'));

        registeredContracts += 1;

-       if (shouldLaberAddresses) {          
+       if (shouldLabelAddresses) {          
            vm.label(address(target), string(abi.encodePacked(addressLabelPrefix, name)));
        }
    }

    function labelAddresses(string memory prefix) public {
-       shouldLaberAddresses = true;       
+       shouldLabelAddresses = true;       
        addressLabelPrefix = prefix;
    }
    ....
}

Developer Response:

Fixed in 9879ef8.

I-4 Finding

I-4: Redundant address cast in `JsonRegistry.register`

Informational

Summary:

JsonRegistry.register() wraps the already-typed address target in an unnecessary address() cast before passing it to Foundry’s vm.label.

This extra cast is unnecessary.

Description:

function register(string memory name, address target) public {
        deploymentOutput = (registeredContracts == 0)
            ? string(abi.encodePacked(deploymentOutput, '    "', name, '": "', vm.toString(target), '"'))
            : string(abi.encodePacked(deploymentOutput, ',\n    "', name, '": "', vm.toString(target), '"'));

        registeredContracts += 1;

        if (shouldLaberAddresses) {
         // Redundant cast — `target` is already an address
            vm.label(address(target), string(abi.encodePacked(addressLabelPrefix, name)));
        }
    }

Here, target is declared as address.
The explicit address(target) conversion therefore performs no type change or runtime work; the compiler treats it as an identity conversion.

Impact:

Informational.

Recommendation:

Remove the redundant cast so the line reads simply:

- vm.label(address(target), string(abi.encodePacked(addressLabelPrefix, name)));
+ vm.label(target, string(abi.encodePacked(addressLabelPrefix, name)));

Developer Response:

Fixed in 3d14c09.

I-5 Finding

I-5: Missing revert messages in multiple `require()` calls

Informational

Summary:

The internal validation functions _verifyAdmin() and _verifyMainnetAddresses() use multiple require() statements without supplying any revert string or custom error. As a result, if one of these checks fails, it is impossible to determine from the revert why it failed.

Description:

In the FullDeployer script:

function _verifyAdmin(CommonInput memory commonInput) internal view {
        require(_isSafeOwner(commonInput.adminSafe, 0x4d47a7a89478745200Bd51c26bA87664538Df541));
        require(_isSafeOwner(commonInput.adminSafe, 0xc599bb54E3BFb6393c7feAf0EC97a947753aC0c8));
        require(_isSafeOwner(commonInput.adminSafe, 0xE9441B34f71659cCA2bfE90d98ee0e57D9CAD28F));
        require(_isSafeOwner(commonInput.adminSafe, 0x5e7A86178252Aeae9cBDa30f9C342c71799A3EE1));
        require(_isSafeOwner(commonInput.adminSafe, 0x9eDec77dd2651Ce062ab17e941347018AD4eAEA9));
        require(_isSafeOwner(commonInput.adminSafe, 0xd55114BfE98a2ca16202Aa741BeE571765292616));
        require(_isSafeOwner(commonInput.adminSafe, 0x790c2c860DDC993f3da92B19cB440cF8338C59a6));
        require(_isSafeOwner(commonInput.adminSafe, 0xc4576CE4603552c5BeAa056c449b0795D48fcf92));
    }
function _verifyMainnetAddresses() internal view {
        require(address(root) == 0x7Ed48C31f2fdC40d37407cBaBf0870B2b688368f);
        require(address(guardian) == 0xFEE13c017693a4706391D516ACAbF6789D5c3157);
        require(address(gasService) == 0x295262f96186505Ce67c67B9d29e36ad1f9EAe88);
        require(address(gateway) == 0x51eA340B3fe9059B48f935D5A80e127d587B6f89);
        require(address(multiAdapter) == 0x457C91384C984b1659157160e8543adb12BC5317);
        require(address(messageProcessor) == 0xE994149c6D00Fe8708f843dc73973D1E7205530d);
        ...
}

Each require here omits a revert reason string. When one of these checks fails, either because an expected admin is missing or because an on-chain address differs from the known mainnet address, these calls will revert with a generic error and no indication of which check did not pass.

Impact:

Informational. Deployments or upgrade scripts that hit one of these error checks will revert with no contextual error. Developers and operators cannot quickly identify which admin or contract address was out of alignment.

Recommendation:

Add descriptive revert strings or include custom errors to capture the failing values.

Developer Response:

Fixed in 9c73575.

I-6 Finding

I-6: Some entries in alchemy_networks.json are outdated

Informational

Summary:

In alchemy_networks.json testnet section, some entries are outdated testnet names. For example, "polygon-mumbai" and "zksync-goerli" aren't supported in Alchemy anymore.

Description:

alchemy_networks.json is loaded in load_config.py, specifically by get_alchemy_rpc_url() getter. This data is propagated to other functions through the call chain. The outdated network name will trigger an error when fetching from RPC.

Impact:

Informational. Error when fetching RPC might bring in debugging overhead.

Recommendation:

Double-check each entry in alchemy_networks.json to make sure every network name is up-to-date and supported. Also, the note in metadata says, "AI generated list: may not be a comprehensive list of all Alchemy-supported networks." If any other such list is generated by AI, it is better to double-check that too.

Developer Response:

Fixed in 2692669.

I-7 Finding

I-7: Incorrect filename for deployment output

Informational

Summary:

The filename with the JSON output for the deployment results has the incorrect placeholder for the block number.

Description:

The saveDeploymentOutput() function uses block.chainid instead of block.number while constructing the filename.

40:         // Save with timestamp for history
41:         string memory timestampedPath = string(
42:             abi.encodePacked(
43:                 dir,
44:                 vm.toString(block.chainid),
45:                 "_block",
46:                 vm.toString(block.chainid),
47:                 "_nonce",
48:                 vm.toString(vm.getNonce(msg.sender)),
49:                 ".json"
50:             )
51:         );

Impact:

Informational.

Recommendation:

Change line 46 to vm.toString(block.number).

Developer Response:

Fixed in 823bfdf.

I-8 Finding

I-8: Improve checks for bridge configuration

Informational

Summary:

The FullDeployer script doesn't validate that the bridge configuration is present when it should be deployed.

Description:

When shouldDeploy == true, the configuration variables are loaded using a "parse or default" strategy, which would leave null addresses.

148:         AdaptersInput memory adaptersInput = AdaptersInput({
149:             wormhole: WormholeInput({
150:                 shouldDeploy: _parseJsonBoolOrDefault(config, "$.adapters.wormhole.deploy"),
151:                 relayer: _parseJsonAddressOrDefault(config, "$.adapters.wormhole.relayer")
152:             }),
153:             axelar: AxelarInput({
154:                 shouldDeploy: _parseJsonBoolOrDefault(config, "$.adapters.axelar.deploy"),
155:                 gateway: _parseJsonAddressOrDefault(config, "$.adapters.axelar.gateway"),
156:                 gasService: _parseJsonAddressOrDefault(config, "$.adapters.axelar.gasService")
157:             })
158:         });

Impact:

Informational.

Recommendation:

Require the presence of valid addresses in the JSON config when shouldDeploy is true.

Developer Response:

Fixed in a2a328e.

I-9 Finding

I-9: Old naming references in OnOfframpManager

Informational

Summary:

There are some references to the old spoke contract that was changed as part of the ContractUpdater refactor.

Description:

Impact:

Informational.

Recommendation:

Rename these to reference the ContractUpdater.

Developer Response:

Fixed in 91355d5.

I-10 Finding

I-10: Version variable cannot be empty

Informational

Summary:

The version variable can never be empty (bytes32(0)) as it is the result of a hash.

Description:

In the FullDeployer script, the version variable is the result of hashing the VERSION env variable or the default empty string if not present.

145:             version: keccak256(abi.encodePacked(vm.envOr("VERSION", string(""))))

However, the CommonDeployer script checks the version against the bytes32(0) value, which can never happen.

125:         if (version != bytes32(0)) {

Impact:

Informational.

Recommendation:

If the VERSION environment variable is not present, default the version to bytes32(0) instead of keccak256("").

Developer Response:

Fixed in 707f395.

I-11 Finding

I-11: Create3-Based Deployment Scripts Are Incompatible with zkSync Era

Informational

Summary:

The current deployment flow relies on CreateX’s create3() helper to deterministically deploy every contract.

zkSync Era’s modified VM only guarantees safe deployment through CREATE and CREATE2 when the compiler already knows the exact bytecode. Any raw‐assembly create/create2 sequence that injects constructor parameters on-the-fly (the pattern used by create3) is explicitly called out as unsafe and will revert or yield unusable bytecode on EraVM.

Description:

A typical deployment in the scripts looks like:

syncDepositVaultFactory = SyncDepositVaultFactory(
    create3(
        generateSalt("syncDepositVaultFactory-2"),
        abi.encodePacked(
            type(SyncDepositVaultFactory).creationCode,
            abi.encode(address(root), syncManager, asyncRequestManager, batcher)
        )
    )
);

Here,
(a) create3() builds the constructor-prefixed byte-code at run-time with abi.encodePacked().

(b) It forwards that blob to the CreateX factory, which performs an inline-assembly create.

(c) On EraVM, the compiler cannot see this generated blob in advance; therefore, the call is treated as a generic create with unknown byte-code – exactly the scenario the zkSync docs warn will “fail due to unsatisfied EraVM assumptions”.

Impact:

Informational. Any attempt to deploy the protocol on zkSync Era with the current scripts will revert or produce contracts with incorrect bytecode/addresses. The issue does not affect existing deployments, but prevents expansion to zkSync Era until the deployment tool chain is refactored.

Recommendation:

Replace create3() with CREATE2-based factories that EraVM explicitly supports. Publish factory dependencies in advance so the zkSync compiler can validate them.

Developer Response:

Acknowledged. We don't plan to deploy there and don't want to add extra unnecessary complexity.

I-12 Finding

I-12: Three checks in FullDeployer._verifyMainnetAddresses() don’t match documentation

Informational

Summary:

In FullDeployer._verifyMainnetAddresses(), there are three outdated address checks.

Description:

In FullDeployer._verifyMainnetAddresses(), there are three checks that don't match the addresses described in the doc. Specifically:

  1. require(address(asyncRequestManager) == 0x58d57896EBbF000c293327ADf33689D0a7Fd3d9A); but doc says asyncRequestManager should be deployed at 0xF06f89a1b6C601235729A689595571B7455dD433,
  2. require(address(syncDepositVaultFactory) == 0x3568184784E8ACCaacF51A7F710a3DE0144E4f29); but doc says syncDepositVaultFactory should be deployed at 0x21bf2544b5a0B03C8566a16592Ba1B3b192b50Bc,
  3. require(address(asyncVaultFactory) == 0xE01Ce2e604CCe985A06FA4F4bCD17f1F08417BF3); but doc says asyncVaultFactory should be deployed at 0xED9D489BB79c7cB58C522f36fC6944eaA95ce385

Impact:

Informational. These three contracts can be deployed at unexpected addresses.

Recommendation:

Update these three require statements according to the documentation. Ensure that the contracts at the incorrect addresses, if deployed, have been decommissioned and don't have any privileges over system contracts.

Developer Response:

Fixed in 84c50c0.

Final Remarks

The audit focused primarily on the incremental updates to the vault-related contracts up to version 3.0.1, along with the deployment infrastructure and scripts, which form a critical component of the protocol's multi-chain expansion strategy. The deployment system leverages CreateX's CREATE3 functionality to ensure consistent contract addresses across different blockchain networks, a design choice that significantly simplifies cross-chain operations and user experience. One medium-severity finding was identified related to the permissioned deployment protection mechanism in CREATE3 calls. The current salt generation approach, while functional, leaves the protocol vulnerable to frontrunning attacks during deployments. While unlikely, this vulnerability could be exploited to deploy backdoored contracts and potentially compromise the protocol's expansion to other chains. The codebase quality is generally high, with well-structured deployment scripts and clear separation of concerns. Several informational findings were identified that relate to code quality improvements, documentation clarity, validation logic to prevent misconfigurations, and operational robustness, rather than critical security vulnerabilities.

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