As of 3 Nov 2026, Chromia has 2 audit reports by 1 security firm on Trustblock; the latest is by Quantstamp, dated 14 Oct 2024. Those reports list no open critical findings.
Record last updated . It holds audits, findings and contracts up to Nov 2026, and incidents up to Oct 2026. Trustblock does not review code.
Newest first
| Audit | Security firm | Date | Scope | Critical findings | Report |
|---|---|---|---|---|---|
| Chromia - Postchain | Security firm: Quantstamp | Date: | Scope: Files named in the report | No open critical findings | Open · certificate.quantstamp.com |
| Chromia FT4 (token library) | Security firm: Quantstamp | Date: | Scope: Files named in the report | No open critical findings | Open · certificate.quantstamp.com |
Statuses are the security firm’s own, from each report.
| Severity | Open | Acknowledged | Fixed |
|---|---|---|---|
| Critical | 0 | 0 | 0 |
| High | 0 | 0 | 1 |
| Medium | 0 | 1 | 6 |
| Low | 1 | 5 | 12 |
In `withdrawBySnapshot()`, several merkle proofs are provided to validate the `snapshot`. The merkle root containing all of the snapshots is in `extraProof.leaf`. The `stateProof` is a merkle proof that validates that the `snapshot` is in the `extraProof.leaf`. However, there is no validation on `extraProof.root` to confirm that it is part of the `massExitBlock.blockRid`. While `TokenBridge._withdrawRequest()` calls `verifyBlockHeader()`, this step is skipped in `withdrawBySnapshot()`. If any `extraProof.root` can be forged, this would allow anyone to drain the contract during a mass exit. The relevant code has been reproduced below: ```js function withdrawBySnapshot( bytes calldata snapshot, Data.Proof memory stateProof, Data.ExtraProofData memory extraProof ) public virtual whenMassExit whenNotPaused nonReentrant { require(_snapshots[stateProof.leaf] == false, "TokenBridge: snapshot already used"); require(stateProof.leaf == keccak256(snapshot), "TokenBridge: snapshot data is not correct"); require(Hash.hashGtvBytes64Leaf(extraProof.leaf) == extraProof.hashedLeaf, "Postchain: invalid EIF extra data"); // Internal integrity of the `extraProof` is validated. bytes32 stateRoot = _bytesToBytes32(extraProof.leaf, 32); // The final reference to `extraProof` if (!MerkleProof.verify(stateProof.merkleProofs, stateProof.leaf, stateProof.position, stateRoot)) // The final reference to `stateRoot`. No validation against the `massExitBlock`. revert("TokenBridge: invalid merkle proof"); .... } ```
Open the report`_withdrawRequest()` allows withdrawals during a mass exit so long as the block is older than the `massExitBlock`. This allows users to execute withdrawals that they've already initiated on the Chromia side, as they would not appear in the token snapshot for the `massExitBlock`. Though the old block will predate the `massExitBlock`, signifying that it was mined before the validators were compromised, it is still the compromised validators who are signing off on the submitted `blockRid` in the withdrawal request. The compromised validators can approve a forged `blockRid` that claims to be older than `massExitBlock`.
Open the reportIn the operation `link_evm_eoa_account()`, two authentications occur: 1. `authenticate()` is called to get the account associated with the operation call. 1. `ft4.auth.verify_signers([address]);` ensures that the EVM address has signed the message permitting the link. <br> However, the message signed by the EVM address does not appear to have any reference to the FT4 account. An example message format is as follows: ``` Blockchain: 0000000000000000000000000000000000000000000000000000000000000000 Please sign the message to call operation: - eif.hbridge.link_evm_eoa_account with arguments: - E1FAE9B4FAB2F5726677ECFA912D96B0B683E6A9 Nonce: 165436D801A7942A5D454666B91070AAFF5FE724CCEB74BA99830D380E17D399 ``` <br> In the above, `E1FAE9B4FAB2F5726677ECFA912D96B0B683E6A9` corresponds to the EVM address. The `Nonce` field does not contain any reference to the FT4 address except for the `auth_descriptor.ctr` (as seen in `utils.derive_nonce(op, auth_descriptor.ctr)` in `authentication.rell`). As long as the attacker has an `auth_descriptor` with the same `ctr` as the honest owner of the EVM address, they may be able to front-run linking the EVM address, potentially stealing deposited funds.
Open the reportThe bridge owner has the power to both call `triggerMassExit()` and `postponeMassExit()`. Despite its name, `postponeMassExit()` does not delay the mass exit, but instead returns the contract to normal operations. Additionally, conventional bridge withdrawals and withdrawals by snapshot are tracked differently. Completed withdrawals done by `withdrawRequest()` are tracked in the `_events` mapping and the withdrawals done by `withdrawBySnapshot()` are tracked in the `_snapshots` mapping. The token bridge owner can batch their transactions such that they trigger a mass exit, withdraw tokens by snapshot, and postpone the mass exit to return the contract to normal operations. They could then withdraw the same tokens normally at their convenience with the usual withdrawal method.
Open the reportThe token bridge owner can trigger a mass exit if the validators are suspected of being compromised. This allows all users to withdraw their tokens according to a snapshot at the last valid block. The owner is allowed to withdraw the remaining contract balance after 90 days. Additionally, the ability to pause and unpause the contract is spread between the contract owner and the validators. The validators can pause the contract and the owner can unpause it. Should the contract be paused for any reason, a malicious owner is incentivized to keep all withdrawals halted and guarantee the full contract balance for themselves. They can call `triggerMassExit()`, regardless of whether the validators have been compromised, and withdraw the full contract balance after 90 days.
Open the reportThe most privileged auth descriptor for an account is the main auth descriptor. On account creation, the main auth descriptor requires both the Account flag and the Transfer flag (`A` and `T` flags respectively). This gives it access to all account and transfer operations. Additionally, the main auth descriptor is the only auth descriptor that can call `update_main_auth_descriptor()`. However, the new main auth descriptor is not validated to have the `A` and `T` flags. This could leave the account in a state where no auth descriptors are capable of account or transfer operations, potentially locking all funds held by the account.
Open the report`add_auth_descriptor()` makes a call to `validate_auth_descriptor_args()`. Unlike `validate_auth_descriptor_rules()`, `validate_auth_descriptor_args()` doesn't perform any validation on the auth descriptor. This makes it possible to set `signatures_required == 0` for the `multi_sig_args`. In the case of `ft_auth`: Given how the `for` loop in `check_multi_sig_auth()` works, at least one signature is required to reach the `if` statement checking whether the number of signatures has been reached. However, should this implementation ever change, the vulnerability may leak through. In the case of `evm_auth`: `signatures_required == 0` allows **anyone** to use the auth descriptor.
Open the reportA call to `postponeMassExit()` returns the contract back to normal functionality after a mass exit has been triggered. If any withdrawals have occurred, this would lead to a deficit in the bridge, as the contract will not have enough balance to honor all withdrawals made with `withdrawRequest()`. Given that this possibility has been mentioned in the documentation, the issue has been assigned Informational severity.
Open the reportFunction `ManagedValidator.setBlockchainRid()` lacks any access controls and may be called by anyone. They can set an arbitrary `blockchainRid` value, impacting any validations being performed on that contract.
Open the reportThe `transferMintRole()` and `transferOwnership()` functions use a two-week delay. The delay is reset if a new pending address is called. The relevant code is reproduced below: ```js function transferMintRole(address newMinter) external onlyOwner { if (pendingNewMinter != address(0)) resetDelayForFunction(this.transferMintRole.selector); startDelayedAction(this.transferMintRole.selector); pendingNewMinter = newMinter; } function transferOwnership(address newOwner) public override onlyOwner { if (pendingOwner() != address(0)) resetDelayForFunction(this.transferOwnership.selector); startDelayedAction(this.transferOwnership.selector); super.transferOwnership(newOwner); } ``` <br> `startDelayAction()` will revert if a delay is active. This means that these functions will always revert if the pending address is `address(0)` and there is an active delay. This could result in a cumbersome transfer of addresses as the owner waits for the two week delay to complete before reassigning the addresses. If a transfer of the minter role is needed, it could also lead to the `ChromiaToken_Base()` not having an assigned minter for a period of time. Depending on the requirements of the contract, having no minter assigned may be prohibitive, forcing a launch of a new contract or keeping the current minter.
Open the reportThe `TokenMinter` contract enforces a two-week delay for increasing the `dayLimit` and no delay for decreasing it. If a new decreased `dayLimit` is assigned, it will not clear any `pendingDayLimit` increases. This can result in the `dayLimit` being decreased, quickly followed by the previous `pendingDayLimit` completing its two-week delay and being assigned. The code has been reproduced below: <br> ```js function setDayLimit(uint _newDayLimit) external onlyOwner { if (_newDayLimit > dayLimit) { // if we already have a pending change, reset it if (pendingDayLimit != 0) resetDelayForFunction(this.setDayLimit.selector); // Set pending day limit and start the two-week delay pendingDayLimit = _newDayLimit; startDelayedAction(this.setDayLimit.selector); } else { // If the new limit is lower, apply immediately dayLimit = _newDayLimit; emit DayLimitChanged(_newDayLimit); } } function finishSetDayLimit() external onlyOwner { finishDelayedAction(this.setDayLimit.selector); dayLimit = pendingDayLimit; delete pendingDayLimit; emit DayLimitChanged(dayLimit); } ```
Open the reportTo initially configure the bridge, the owner can call `setBlockchainRid()` to point to the chain from which the bridge can receive withdrawals. However, the owner can reassign this value at any time, making it impossible to withdraw previous deposits.
Open the reportA mass exit is triggered once it is suspected that the validators cannot be trusted. The bridge owner can do this by calling `triggerMassExit()` and assign the `massExitBlock`. If the `massExitBlock` is incorrectly assigned, perhaps due to an incorrect assessment of the last block signed by uncompromised validators, the `postponeMassExit()` allows the owner to undo this action. However, `postponeMassExit()` immediately returns the contract to normal functionality. This would allow further withdrawals to be performed, some of which may be signed by compromised validators.
Open the reportThe `get_account_by_auth_descriptor()` query assumes that the `account.id == auth_descriptor.hash()`. However, the `account.id` is not constructed in this way. The query will mistakenly return `null`.
Open the reportIn `rell/src/lib/ft4/external/admin/operations.rell`, the function `add_rate_limit_points()` has no validation on `amount`, which could allow the admin to exceed `rl_config.max_points` (or alternatively input a negative number). Notably, this may affect priority check calculations in `rell/src/lib/ft4/core/prioritization/default/module.rell`. The calculated `priority` value appears to normally fall into the range `[0.0, 1.0]`, however this invariant may break if an improper amount of points are added.
Open the reportThe function `delete_all_auth_descriptors_except_main_message(gtv)` returns the following string: `Please sign the message\nto delete all auth descriptors except main:\n%s\n\n from account:\n{account_id}`. Note that it contains a "%s" however the function does not invoke `format()` on the string. The `gtv` parameter is unused, and its not clear how the `account_id` will be populated. If the string goes unformatted, the message can be used for any `account_id`.
Open the report`generate_operation_auth_message()` generates a message for an operation for users to sign. Backslashes are removed from the arguments before being added to the message. This loss of information results in differing arguments conforming to the same message. For example, `"\ ".replace("\"", "") == " \".replace("\"", "")`. In this scenario, both messages could be signed with the same signatures.
Open the reportIt is important to validate inputs, even if they only come from trusted addresses, to avoid human error. The following functions would benefit from additional validation: - `register_crosschain_asset()`: Parameters `origin_blockchain_rid` and `issuing_blockchain_rid` are not checked to be different from `chain_context.blockchain_rid`. - `create_rate_limiter_state_for_account()`: Value `points_at_start` not checked to be lower than `rl_config.max_points`.
Open the reportIn `validate_auth_descriptor_rules()`, the length of `rules_gtv` is validated with the following line in the `else` branch: ``` require(rules_gtv.size() <= chain_context.args.auth_descriptor.max_rules, "Too many rules"); ``` Since "and" occupies slot 0, this only permits `max_rules - 1`.
Open the reportThe reports name a repository or files. Open a report to see its scope.
Is this your project? Show this record on your own website. The label states what the record holds, links to this page and updates by itself. It is free and needs no sign-in.
Automatic: the three strongest facts of this project’s own record (its last audit and two more), highlighted. They update with the record. Picking a fact turns Automatic off.
<head><script src="https://widget.trustblock.run/project-widget.min.js" defer></script></head><body><divdata-tb-project-widgetdata-tb-project-slug="chromia"></div></body>
Paste the code where the label should appear. It uses no API credit and updates by itself.
Something wrong on this page? A missing report, a wrong date, a finding that was fixed since. Tell us and we check it against its source.
Tell us