Download audit report
This document presents the results of the security review conducted by Nethermind Security for ParticleCS's Bloxchain protocol contracts which is an enterprise-grade security framework for building smart contracts.
The core thesis is that a single compromised key should never be sufficient to immediately do damage. The protocol enforces this architecturally through two parallel workflows that integrators can mix freely per operation:
ConflictingMetaTxPermissions invariant — a single role cannot hold both SIGN_META_* and EXECUTE_META_* permissions for the same selector.The reference identity model uses three protected roles that are bootstrapped at deployment and cannot be removed: Owner, Broadcaster, and Recovery. On top of these, integrators may use the RuntimeRBAC component to define and manage arbitrary additional roles dynamically.
The audit comprises 3800 lines of Solidity code. The audit was performed using (a) manual analysis of the codebase, and (b) automated analysis tools.
| Audit assessment | |
| Audit Type | Security Review |
| Initial Report | May 15, 2026 |
| Final Report | May 28, 2026 |
| Initial Commit | 8bbef3ab46 |
| Final Commit | cf91f1a2b9 |
| Documentation Assessment | High |
| Test Suite Assessment | High |
Findings by severity
| # | Contract | LoC | Comments | Ratio | Blank | Total |
| 1 | access/RuntimeRBAC.sol | 112 | 123 | 109% | 21 | 255 |
| 2 | access/interface/IRuntimeRBAC.sol | 19 | 30 | 157% | 6 | 55 |
| 3 | access/lib/definitions/RuntimeRBACDefinitions.sol | 147 | 99 | 67% | 44 | 290 |
| 4 | security/SecureOwnable.sol | 195 | 215 | 110% | 46 | 456 |
| 5 | security/interface/ISecureOwnable.sol | 16 | 72 | 450% | 17 | 105 |
| 6 | security/lib/definitions/SecureOwnableDefinitions.sol | 543 | 149 | 27% | 110 | 802 |
| 7 | execution/GuardController.sol | 203 | 209 | 103% | 43 | 454 |
| 8 | execution/interface/IGuardController.sol | 53 | 81 | 152% | 12 | 146 |
| 9 | execution/lib/definitions/GuardControllerDefinitions.sol | 372 | 142 | 38% | 75 | 589 |
| 10 | lib/EngineBlox.sol | 1396 | 999 | 71% | 310 | 2683 |
| 11 | lib/utils/SharedValidation.sol | 234 | 241 | 103% | 65 | 540 |
| 12 | lib/interfaces/IEventForwarder.sol | 12 | 19 | 158% | 2 | 33 |
| 13 | lib/interfaces/IDefinition.sol | 12 | 32 | 266% | 5 | 49 |
| 14 | pattern/Account.sol | 34 | 44 | 129% | 6 | 84 |
| 15 | base/BaseStateMachine.sol | 414 | 462 | 111% | 94 | 967 |
| 16 | base/interface/IBaseStateMachine.sol | 38 | 95 | 250% | 20 | 153 |
| Total | 3800 | 3012 | 79.3% | 876 | 7661 |
The design rests on three principles that together define the protocol's security posture: a single source of mutation, mandatory two-signature authorization, and defense in depth via redundant gates.
Single source of mutation. All mutable state lives in one struct (SecureOperationState) instantiated once per deployed contract. Only the EngineBlox library may mutate it, which isolates the protocol's invariants to a single audit surface.
Two-signature mandatory. Every state-changing operation must be authorized by at least two distinct parties — either across time (request now, approve later, with a window to intervene) or across roles (signer ≠ executor for meta-tx). This is enforced architecturally rather than by convention.
Defense in depth via redundant gates. Where one check could be sufficient, the protocol layers two or more checks that look at the same property from different angles: identity vs. role membership, permission grant vs. handler/execution wiring, and storage status vs. structural invariants. A single compromised layer cannot bypass the others.
The framework is organized as three tiers — a stateless library at the top, a single base contract that owns the protocol's storage, and three composable components that each contribute a slice of the public surface. Sections 4.1.1 through 4.1.3 describe each tier, and Section 4.1.4 explains how a concrete deployable contract is assembled from them.
4.1.1 EngineBlox
EngineBlox is a Solidity library, it owns no storage of its own. Its public functions are linked into integrating contracts via DELEGATECALL, so when EngineBlox runs, address(this) and storage context belong to the integrating contract. The library mutates whatever SecureOperationState storage reference the caller passes in.
4.1.2 BaseStateMachine
BaseStateMachine is the only contract that declares _secureState. All component contracts inherit from it, directly or transitively. This guarantees a single _secureState slot per deployed contract — regardless of how many components are composed.
4.1.3 Components
Each component (SecureOwnable, RuntimeRBAC, GuardController) adds its own public surface — the user-facing operations for ownership transfer, role config, and external execution — plus its own definitions library that declares its function schemas and role permission grants.
4.1.4 Concrete deployable contracts
A concrete deployable contract inherits whichever components it needs. A wallet-style contract typically composes all three. A vault, an ERC20, or a payment scheduler may inherit only SecureOwnable. A clone factory may inherit only BaseStateMachine. The composition is per-deployment; the framework does not prescribe a single pattern.
Every operation in the protocol is represented as a TxRecord indexed by a monotonically-increasing txId, and each record carries a single TxStatus enum value that tracks its progression through the lifecycle:
The protocol exposes two parallel workflow patterns that integrators may mix per operation. Both perform the same PENDING → EXECUTING → terminal state (COMPLETED, FAILED, or CANCELLED) progression; they differ in how authorization is supplied and whether a wait window is enforced.
4.2.1 Time-delay flow
This pattern uses two on-chain transactions over a configured window. An initiator with EXECUTE_TIME_DELAY_REQUEST permission creates a PENDING record with releaseTime = block.timestamp + timeLockPeriodSec. After the window elapses, an approver with EXECUTE_TIME_DELAY_APPROVE permission moves the record through EXECUTING to a terminal state (COMPLETED, FAILED, or CANCELLED). A separate cancel path with EXECUTE_TIME_DELAY_CANCEL permission can short-circuit to CANCELLED at any time during the window.
4.2.2 Meta-transaction flow
This pattern uses one on-chain transaction with two distinct roles. A signer with SIGN_META_* permission produces an EIP-712 typed-data signature off-chain over the full MetaTransaction struct. An executor with EXECUTE_META_* permission then submits the signed message on-chain. EngineBlox runs a full integrity battery (signature length, chainId, deadline, gasprice, nonce, handlerContract bind, signer-permission dual check, ECDSA recovery) before incrementing the signer's nonce and moving the record through EXECUTING to a terminal state within the same call.
A fused “request-and-approve” meta-tx variant is used for operations the protocol designers chose not to gate with a time-delay — recovery rotation, time-lock change, role-config batch, and guard-config batch. The two-signature property is preserved, but the time-lock observation window is omitted.
4.2.3 External execution
The EXECUTING transition is the only moment the protocol calls arbitrary external code. Returndata copying is bounded to 32 KiB, defending against returnbomb griefing. The target address must clear a per-function target whitelist (functionTargetWhitelist[executionSelector]); an empty whitelist means deny, with no implicit wildcard. A separate system macro selector set permits target == address(this) only for specific intentionally-self-call execution selectors (e.g., executeTransferOwnership). The whitelist is checked at request time and re-checked before payment-leg external calls inside executeAttachedPayment.
The risk rating methodology used by Nethermind Security follows the principles established by the OWASP Foundation. The severity of each finding is determined by two factors: Likelihood and Impact.
Likelihood measures how likely the finding is to be uncovered and exploited by an attacker. This factor will be one of the following values:
When defining the likelihood of a finding, other factors are also considered. These can include but are not limited to motive, opportunity, exploit accessibility, ease of discovery, and ease of exploit.
Impact is a measure of the damage that may be caused if an attacker exploits the finding. This factor will be one of the following values:
When defining the impact of a finding, other factors are also considered. These can include but are not limited to data and state integrity, loss of availability, financial loss, and reputation damage. After defining the likelihood and impact of an issue, the severity is determined according to the table below.
| Impact / Likelihood | Low | Medium | High |
| High | Medium | High | Critical |
| Medium | Low | Medium | High |
| Low | Info / Best Practices | Low | Medium |
| Undetermined | Undetermined | Undetermined | Undetermined |
To address issues that do not fit a High, Medium or Low severity, Nethermind Security also uses three more finding severities: Informational, Best Practices, and Undetermined.
PENDING state to become stuck| File | Fix commit | Type | Status |
contracts/core/lib/EngineBlox.sol | 72d67da | Low | Fixed |
A pending transaction in EngineBlox can leave the PENDING status only through the approval paths or through the cancellation paths. Both ways verifies that the target for the transaction is whitelisted:
function _cancelTransaction(SecureOperationState storage self, uint256 txId) private {
// @audit-issue This call blocks cancellation when the target is no longer whitelisted.
_validateTargetWhitelist(
self,
self.txRecords[txId].params.executionSelector,
self.txRecords[txId].params.target
);
self.txRecords[txId].status = TxStatus.CANCELLED;
removePendingTx(self, txId);
logTxEvent(self, txId, self.txRecords[txId].params.executionSelector);
}This can cause a scenario where a transaction is stuck in PENDING state:
txRequest(...) with target T, while T is whitelisted for executionSelector. The record is stored with status = PENDING;T from self.functionTargetWhitelist[executionSelector];txDelayedApproval(...) or _txApprovalWithMetaTx(...) reverts inside _completeTransaction(...) on the whitelist check, after consuming gas for the external call;txCancellation(...) or txCancellationWithMetaTx(...) reverts inside _cancelTransaction(...) on the same whitelist check, before any state transition can occur;The record is stuck in TxStatus.PENDING indefinitely. The only way to unblock it is for an administrator to re-add T to the whitelist.
Recommendation
Consider removing the call to _validateTargetWhitelist(...) from _cancelTransaction(...). Cancellation does not interact with the target and does not depend on the target being currently authorized.
Resolution
Removed _validateTargetWhitelist(...) from _cancelTransaction(...). Fixed in commit 72d67d.
| File | Fix commit | Type | Status |
contracts/core/lib/EngineBlox.sol | 72d67da | Low | Fixed |
Target whitelisting is enforced through _validateTargetWhitelist(...), which checks that target is registered for executionSelector in self.functionTargetWhitelist. The goal is to restrict every approved transaction to a destination that was authorized before.
When txDelayedApproval(...) is called, the functions perform the external call first and after that they call _completeTransaction(...), which is where the whitelist is checked:
function txDelayedApproval(SecureOperationState storage self, uint256 txId, bytes4 handlerSelector)
public
returns (TxRecord memory)
{
// ...
self.txRecords[txId].status = TxStatus.EXECUTING;
// INTERACT: External call after state update
(bool success, bytes memory result) = executeTransaction(self, self.txRecords[txId]);
// @audit-issue Whitelist is enforced only after the external call has run.
_completeTransaction(self, txId, success, result);
// ...
}function _completeTransaction(
SecureOperationState storage self,
uint256 txId,
bool success,
bytes memory result
) private {
// @audit-issue This check belongs before `executeTransaction(...)`, not after it.
_validateTargetWhitelist(
self,
self.txRecords[txId].params.executionSelector,
self.txRecords[txId].params.target
);
// ...
}The same pattern repeats in _txApprovalWithMetaTx(...), which also calls executeTransaction(...) before _completeTransaction(...).
Two consequences follow from this ordering.
First, gas is wasted on the unhappy path. If the target is no longer whitelisted at approval time, for example because an admin removed it between request and approval, the engine still flips status to EXECUTING, forwards the configured gas to the target, and lets the target execute arbitrary code before _completeTransaction(...) reverts the entire transaction on the late whitelist check. The caller pays for all of that work even though the call should never have been issued.
Second, the late check breaks checks-effects-interactions pattern.
Recommendation
Consider moving _validateTargetWhitelist(...) so that the whitelist is enforced before any external call is made.
Resolution
The finding was valid as a gas-efficiency and CEI-ordering issue. _validateTargetWhitelist(...) was moved out of _completeTransaction(...) and is enforced before any external call on every approval path (txDelayedApproval(...) and _txApprovalWithMetaTx(...). Fixed in commit 72d67d.
record.status and record.result in executeTransaction(...) have no effect| File | Fix commit | Type | Status |
contracts/core/lib/EngineBlox.sol | 72d67da | Info | Fixed |
The executeTransaction(...) helper performs the low-level call associated with an approved transaction. The helper accepts the transaction record as TxRecord memory record, which is a memory copy of self.txRecords[txId] taken at the call site.
After performing the external call, the helper transitions the record status and store the returned data depending on the call outcome:
function executeTransaction(SecureOperationState storage self, TxRecord memory record)
private
returns (bool, bytes memory)
{
// ...
if (success) {
// @audit-issue Writing to a memory copy has no effect on the persisted record.
record.status = TxStatus.COMPLETED;
record.result = result;
if (record.payment.recipient != address(0)) {
executeAttachedPayment(self, record);
}
} else {
// @audit-issue Writing to a memory copy has no effect on the persisted record.
record.status = TxStatus.FAILED;
record.result = result;
}
return (success, result);
}Because record is a memory parameter rather than a storage reference, both branches mutate a local copy that is discarded once the function returns. The persisted status and result are written only later by _completeTransaction(...), which the callers invoke after executeTransaction(...) returns. The intermediate memory writes are dead code. They are not consumed by the caller, the nested executeAttachedPayment(...) re-reads status from storage via _validateTxStatus(self, record.txId, TxStatus.EXECUTING) instead of from record.status, and any external reader of self.txRecords[txId] observes the values set by _completeTransaction(...).
Recommendation
Consider removing the assignments to record.status and record.result from executeTransaction(...), since they have no effect.
Resolution
Removed dead record.status and record.result assignments. Fixed in commit 72d67d.
validateDeadline(...) and validateMetaTxDeadline(...) behave differently when block.timestamp == deadline| File | Fix commit | Type | Status |
contracts/core/lib/utils/SharedValidation.solcontracts/core/lib/EngineBlox.sol | 72d67da | Info | Fixed |
The protocol uses two different helpers to decide whether the deadline field of a meta-transaction is still valid. They are intended to express the same predicate but they treat the case block.timestamp == deadline differently.
function validateDeadline(uint256 deadline) internal view {
// @audit-issue Strict comparison rejects `block.timestamp == deadline`.
if (deadline <= block.timestamp) revert DeadlineInPast(deadline, block.timestamp);
}function validateMetaTxDeadline(uint256 deadline) internal view {
// @audit-issue Non-strict comparison accepts `block.timestamp == deadline`.
if (block.timestamp > deadline) revert MetaTxExpired(deadline, block.timestamp);
}validateDeadline(...) is used in generateMetaTransaction(...), which is the helper that builds the MetaTransaction payload that an off-chain signer is expected to sign. validateMetaTxDeadline(...) is invoked from the meta-transaction verification path that runs inside verifySignature(...) and is reached from txApprovalWithMetaTx(...), txCancellationWithMetaTx(...), and the request-and-approve meta-tx variants.
This produces an inconsistency at the exact second equal to deadline. A meta-transaction whose deadline matches the current block timestamp at verification time is still executed normally, because validateMetaTxDeadline(...) admits the boundary. The same deadline, however, cannot be used to construct a fresh MetaTransaction payload through generateMetaTransaction(...), because validateDeadline(...) rejects it.
Recommendation
Consider aligning the two helpers on the same boundary semantics.
Resolution
Removed validateDeadline(...) and unified on validateMetaTxDeadline(...). Fixed in commit 72d67d.
validateTransactionExists(...) does not bound txId against txCounter| File | Fix commit | Type | Status |
contracts/core/lib/utils/SharedValidation.solcontracts/core/lib/EngineBlox.sol | 72d67da | Info | Fixed |
The validateTransactionExists(...) helper in SharedValidation is named so that callers can rely on it to confirm that a given txId corresponds to a real transaction record. Its current implementation only rejects the zero value:
function validateTransactionExists(uint256 txId) internal pure {
if (txId == 0) revert ResourceNotFound(bytes32(uint256(txId)));
}In EngineBlox, transaction identifiers are minted sequentially. Each new record receives txId = self.txCounter + 1 inside createTxRecord(...), and self.txCounter is then incremented in txRequest(...), so any allocated identifier lies in the inclusive range [1, self.txCounter]. Identifiers strictly greater than self.txCounter are not valid attached to any existing record, however the function accepts them as if they did.
Recommendation
Consider modifying validateTransactionExists(...) so that it also rejects any txId greater than the current self.txCounter.
Resolution
Added txCounter parameter and reject txId > txCounter. Fixed in commit 72d67d.
Software documentation refers to the written or visual information that describes the functionality, architecture, design, and implementation of software. It provides a comprehensive overview of the software system and helps users, developers, and stakeholders understand how the software works, how to use it, and how to maintain it. Software documentation can take different forms, such as user manuals, system manuals, technical specifications, requirements documents, design documents, and code comments. Software documentation is critical in software development, enabling effective communication between developers, testers, users, and other stakeholders. It helps to ensure that everyone involved in the development process has a shared understanding of the software system and its functionality. Moreover, software documentation can improve software maintenance by providing a clear and complete understanding of the software system, making it easier for developers to maintain, modify, and update the software over time. Smart contracts can use various types of software documentation. Some of the most common types include:
These types of documentation are essential for smart contract development and maintenance. They help ensure that the contract is properly designed, implemented, and tested, and they provide a reference for developers who need to modify or maintain the contract in the future.
The Particle CS team provided documentation detailing the full Bloxchain protocol architecture. Additionally, the team provided a walkthrough of the entire protocol including all the main flows with expected state changes. Furthermore, the team addressed all questions and concerns raised by the Nethermind Security team, providing valuable insights and a comprehensive understanding of the project's technical aspects.
| 336Tests passed | 0Failed |
The complete test output is in the full report.
As auditors, we recommend that any changes or updates made to the audited codebase undergo a re-audit or security review to address potential vulnerabilities or risks introduced by the modifications. By conducting a re-audit or security review of the modified codebase, you can significantly enhance the overall security of your system and reduce the likelihood of exploitation. However, we do not possess the authority or right to impose obligations or restrictions on our clients regarding codebase updates, modifications, or subsequent audits. Accordingly, the decision to seek a re-audit or security review lies solely with you.
This report is based on the scope of materials and documentation provided by you to Nethermind in order that Nethermind could conduct the security review outlined in 1. Executive Summary and 2. Audited Files. The results set out in this report may not be complete nor inclusive of all vulnerabilities. Nethermind has provided the review and this report on an as-is, where-is, and as-available basis. You agree that your access and/or use, including but not limited to any associated services, products, protocols, platforms, content, and materials, will be at your sole risk. Blockchain technology remains under development and is subject to unknown risks and flaws. The review does not extend to the compiler layer, or any other areas beyond the programming language, or other programming aspects that could present security risks. This report does not indicate the endorsement of any particular project or team, nor guarantee its security. No third party should rely on this report in any way, including for the purpose of making any decisions to buy or sell a product, service or any other asset. To the fullest extent permitted by law, Nethermind disclaims any liability in connection with this report, its content, and any related services and products and your use thereof, including, without limitation, the implied warranties of merchantability, fitness for a particular purpose, and non-infringement. Nethermind does not warrant, endorse, guarantee, or assume responsibility for any product or service advertised or offered by a third party through the product, any open source or third-party software, code, libraries, materials, or information linked to, called by, referenced by or accessible through the report, its content, and the related services and products, any hyperlinked websites, any websites or mobile applications appearing on any advertising, and Nethermind will not be a party to or in any way be responsible for monitoring any transaction between you and any third-party providers of products or services. As with the purchase or use of a product or service through any medium or in any environment, you should use your best judgment and exercise caution where appropriate. FOR AVOIDANCE OF DOUBT, THE REPORT, ITS CONTENT, ACCESS, AND/OR USAGE THEREOF, INCLUDING ANY ASSOCIATED SERVICES OR MATERIALS, SHALL NOT BE CONSIDERED OR RELIED UPON AS ANY FORM OF FINANCIAL, INVESTMENT, TAX, LEGAL, REGULATORY, OR OTHER ADVICE.
The Leading Engineers of Blockchain Infrastructure