Download audit report
This document presents the results of the security review conducted by Nethermind Security for Karnot's Arcx, a DeFi protocol that allows users to split strategies into separate yield and rewards exposure, then trade each side independently.
Arcx is a modular protocol on Starknet that combines a vault and points system, which allows yield vaults to split their yield and rewards component into two tokens and then trade them on an internal orderbook. It functions as a yield decomposition primitive that splits a yield-bearing asset into two distinct, tradable components based on the fundamental invariant represented as 1 vToken = 1 ST + 1 EPT.
When a user deposits capital for yield generation, the resulting vault token (vToken) can be decomposed into a Strategy Token (ST), which represents the principal-plus-yield leg redeemable at maturity, and an Exchange Points Token (EPT), which captures all accrued reward points. This architecture allows users to unbundle their investment risks to execute targeted financial strategies, such as selling EPT to lock in an upfront fixed interest rate, purchasing ST at a market discount to secure cheaper principal, or trading either piece independently based on speculation.
The protocol executes these strategies through an automated cross-chain pipeline that bridges deposited USDC from Starknet to the Ethereum EVM via Circle's Cross-Chain Transfer Protocol (CCTP) using atomic hook-data. Upon reaching the EVM, this capital is delegated directly to Wildcat lending markets to generate the underlying strategy yield, while the trading of the ST and EPT pieces occurs back on Starknet through a permissionless, APR-quoted orderbook.
This orderbook supports standard same-asset swaps as well as a cross-asset matching engine that can simultaneously pair independent ST and EPT buyers to mint and distribute fresh token pairs on the fly, before eventually routing withdrawal requests back through the CCTP bridge at maturity.
Net Asset Value (NAV) accrues continuously via a credit-index model, with protocol performance fees taken from both interest and finalized points to fund a configurable treasury.
The entire system is governed through clearly segregated roles, dividing upgrade and fee authority to the Owner, strategy execution to the Operator, capital funding to the OperatorVault, and emergency stop capabilities to designated Pausors.
The audit comprises 5230 lines of cairo code and 169 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 | June 01, 2026 |
| Final Report | June 04, 2026 |
| Initial Commit | 01b6d8c2c7 |
| Final Commit | 8b97dda00f |
| Documentation Assessment | High |
| Test Suite Assessment | High |
Findings by severity
| # | Contract | LoC | Comments | Ratio | Blank | Total |
| 1 | VaultReceiver.sol | 169 | 54 | 32.0% | 38 | 261 |
| Total | 169 | 54 | 32.0% | 38 | 261 |
Arcx is a modular protocol on Starknet that combines a yield-bearing USDC vault with a strategy-based orderbook for trading the vault's principal and yield separately. The protocol is designed with two surfaces that can work together without being tightly coupled. ArcxCore runs the orderbook and uses VaultToken (VT) balances as the collateral and settlement layer for strategy trades. The supporting contracts each handle one concern. ArcxRegistry is the canonical directory of exchanges, vaults, and exchange point tokens, and routes each vault to its EVM market. VaultToken is the ERC-4626 vault that holds user deposits and tracks NAV. ExchangePointToken is the registry-controlled, redemption-enabled token representing exchange points. WithdrawalNFT is the receipt for queued withdrawals. Bridge adapters move USDC between Starknet and EVM-based Wildcat lending markets. Each of these components operates independently and can be upgraded without disrupting the rest of the system, with child tokens such as ExchangePointToken and WithdrawalNFT upgraded through their controlling parent rather than directly.
At a high level, Arcx gives users three capabilities:
Vault + Points Surface: The Vault and Points Surface act as the protocol’s liquidity layer, managing user deposits and rewards. It handles standard ERC-4626 vault accounting, registers exchanges, and tracks cross-chain asset movements. Finally, it uses specialized. Receipt NFTs to securely manage users' asynchronous withdrawal processes.
Strategy + Orderbook Surface: The Strategy and Orderbook Surface sits above the vaults to run the core trading engine and yield strategies. It hosts an APR-based order book that matches trades and breaks down assets so that principal and yield trade separately. This surface handles the activation of new strategies and manages the splitting or merging of tokens.
Separation Model: The Separation Model maintains a strict boundary between the asset vaults and the trading system. Vault contracts require zero knowledge of active trading strategies, meaning the deposit system continues to work even if trading stops. All complex token splitting and merging is handled externally by the trading engine to keep vaults secure
The ArcxRegistry is the central source of truth for the vault system, keeping all protocol data accurate and consistent. It handles the registration of exchanges, deploys point tokens, and creates Vault Tokens (VTs) along with their withdrawal NFTs. By mapping each vault to its exact market path, it serves as the ultimate reference point for the entire network.
Strict rules ensure each exchange has only one point token and each market has only one Vault Token to prevent confusion. Cross-chain bridges pull routing data directly from this registry instead of trusting user-supplied addresses, which eliminates major security risks and errors. Finally, it includes governance controls to pause the system and protect assets during emergencies.
VaultToken (VT): is an ERC-4626 token that handles standard vault deposits while managing two extra features: reward points system and a secure, multi-step withdrawal process.
Deposits and NAV (Net Asset Value): When you deposit funds, the system instantly recalculates the vault’s exact value so your shares are accurately priced. The vault’s value grows at a fixed APR applied at specific intervals, allowing your earnings to compound over time.
Credit Periods and Points: The token automatically tracks user rewards over set time blocks. Once a period ends and the calculations Once finalized, you can claim exchange point tokens proportional to your accumulated credits, ensuring payouts are always accurate and unchangeable.
Withdrawals: Exiting the vault is a multi-step process rather than an instant transaction. When you request a withdrawal, your shares are locked, and you receive a WithdrawalNFT as an on-chain receipt. Once the mandatory waiting period ends, you trade this NFT for your funds. Additionally, new deposits must clear a minimum lockup period before they can enter the withdrawal pipeline.
For each strategy, ArcxCore creates and manages two tokens: ST and EPT. ST is the principal-and-yield side: it redeems 1:1 for the underlying VT at maturity, so it carries both the principal and the vault's NAV yield. EPT is the points/rewards side: it accrues the credits that convert into exchange points, and it never claims the underlying principal. Core can split VT exposure into these two tokens (mint_st_ept) and merge them back into VT (burn_st_ept). This gives users flexibility: instead of holding VT alone, they can trade ST or EPT separately based on their market view.
Core uses a simple structure for this. Every strategy gets a unique, increasing strategy_id. The ST and EPT token contracts for that strategy are deployed under the same id, so lookup and tracking are straightforward. A strategy's maturity (end_time) must align with the VT period schedule, which keeps strategy settlement aligned with vault period boundaries.
In Arcx, every order belongs to one specific strategy. An order always includes which token is traded (ST or EPT), whether it is a BUY or SELL, the APR, the size, and the strategy_id. This keeps each order clear and tied to a single strategy context.
Arcx does not fund orders with a normal transferFrom. Instead, it uses a lock model (approve_to_lock + lock). The token that is locked depends on the order: a BUY locks VT, a SELL ST locks ST, and a SELL EPT locks EPT. If an order is only partly filled, the remaining amount stays locked until it gets matched later or the user cancels the order.
Matching supports four cases: ST buy/sell, EPT buy/sell, cross-asset buy+buy, and cross-asset sell+sell. Same-asset matches trade tokens directly. Cross-asset BUY+BUY follows the mint path (creates ST/EPT from VT), while cross-asset SELL+SELL follows the burn path (recombines ST/EPT back to VT). Settlement depends on APR and remaining time, and always uses the maker's rate.
The matching_time is provided by the caller, but it is strictly checked. It must be close to the current block time (within roughly two minutes of skew), and the strategy must still be active and not matured at that time. So the protocol allows small timing flexibility, but blocks arbitrary timestamps.
Points are created at the exchange point token level. But users cannot claim them immediately. Points become claimable only after VT Closes and finalizes the related credit period. This keeps point accounting tied to one period-based source of truth. For VT holders, the claim flow is simple. They claim directly through VT. VT already tracks credit index, periods, and each holder’s share, So it can calculate and pay claims on its own. For EPT holders, claims go through the EPT contract. EPT does not generate points on its own. Core funds EPT claims using the correct share of points that originated from the VT settlement, so EPT holders receive equivalent economic value. This creates clear role separation. VT is the system’s credit and point finalization engine. EPT is only a strategy-level distribution layer, and it depends on Core funding. So points remain unified at the VT level while still being distributed via strategy tokens.
The bridge flow uses four main parts. BridgeProxy handles cross-chain actions on Starknet, and VaultReceiver handles them on EVM. CCTP carries both funds and messages between chains. Wildcat is the EVM market where bridged liquidity is deployed or withdrawn.
rebalance_liquidity decides which way funds should move. If Starknet has extra deposits, the excess is bridged to EVM so it can earn yield. If Starknet has more mature withdrawals than local liquidity, funds are requested back from EVM to pay users.
The protocol uses a netting-first approach. It first offsets local deposits and local withdrawal needs, then bridges only the leftover difference. This reduces unnecessary bridge transfers, lowers messaging cost, and limits delay risk. Routing is also constrained by registry-approved mappings, so funds move only through approved VT-to-market paths.
The owner registers the exchange and deploys the VT stack via the registry. The core owner creates a strategy for a VT, ST/EPT are deployed, and the strategy is activated. Users deposit base assets into VT. Users place strategy orders after lock approvals. Matcher executes compatible order pairs, settlement moves/locks/mints/burns accordingly. VT periods finalize points, VT and EPT holders claim through their respective flows. VT withdrawal queue processes exit into NFTs, then redeems as liquidity becomes available. Operator/owner rebalances liquidity and bridges residual flows cross-chain.
Strengths
Tradeoffs
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.
| File | Fix commit | Type | Status |
src/components/LockingComponent.cairo | 8b97dda | High | Fixed |
The ST and EPT token contracts gate their burn entrypoint with an assertion ensuring that only the ArcxCore contract can call them. This mechanism ensures that the supply of both tokens moves only in lockstep through the ArcxCore decomposition and recomposition flows. These token contracts embed the LockingComponent to handle token locking capabilities.
However, the LockingComponent exposes the burn_from_using_lock(...) function in its public ABI. This function bypasses the contract-level core restriction by calling the internal burn(...) function of the ERC20 component directly underneath the contract-level access control check. Because the public locking primitives allow any user to authorize themselves as a locker by calling approve_to_lock(...) and lock(...) on their own balance, any standard token holder can establish a self-lock and invoke the public burn_from_using_lock(...) function.
// ...
// @audit-issue The public burn_from_using_lock function bypasses host access controls.
fn burn_from_using_lock(ref self: ContractState, ...) {
// ...
self._consume_locked_amount(...);
self.erc20_component.burn(...);
}This means that an attacker can destroy one leg of the pair by burning ST tokens without burning EPT tokens, or vice versa entirely outside of the _burn_st_ept(...) flow from the ArcxCore contract. This desynchronizes the two token supplies. Because the assert_strategy_collateralization(...) function enforces that st_supply equals ept_supply at the end of the _mint_st_ept(...) and _ burn_st_ept(...) flows, any future symmetric minting, burning, or cross-asset BUY and BUY match settlements will permanently revert. An attacker can permanently disable the core functionalities of an affected strategy at the cost of a single dust unit of 1, leading to a complete denial-of-service (DoS) against the strategy's operational liveness.
Recommendation
Consider removing the burn_from_using_lock(...) function from the public embeddable ABI of the LockingComponent so that it cannot be accessed externally.
Resolution
| File | Fix commit | Type | Status |
src/vt/contract.cairo | bfc6408 | High | Fixed |
The VaultToken contract coordinates yield strategies by partitioning operational timelines into distinct, sequential execution periods. Throughout any active period, user allocations generate rewards which are accumulated as credits. Once a period concludes, the contract owner finalizes the distribution by invoking the submit_period_points(...) function to record the gross exchange points earned, deduct a performance fee for the treasury, and distribute the remaining net points to users.
The owner has the ability to configure the performance fee any time by calling set_performance_fees(...) function. When the owner calls set_performance_fees(...), it first settles NAV under old NAV-fee which only protects the NAV-side fee.
fn set_performance_fees(ref self: ContractState, new_performance_fees: PerformanceFees) {
//....
self._update_nav();
// @audit The new configuration rate is written directly to the global storage state.
self.performance_fees.write(new_performance_fees);
}The points side fee has no analogous to NAV because points aren't continuous, they're submitted in lumps via submit_period_points(...). There's no equivalent of _update_nav() for the points side. So, when the owner subsequently processes an older completed period using the submit_period_points(...) function, the current logic applies this newly updated performance fee rate retroactively.
fn submit_period_points(ref self: ContractState, period_id: u64, final_points: u256) {
// ...
let total_credits = p.period_total_credits;
if final_points > 0 {
assert(total_credits > 0, 'no credits for points');
}
// @audit-issue The contract reads the live rate instead of the rate from the period.
let performance_fees = self.performance_fees.read();
let fee_points = bps_mul_down(final_points, performance_fees.points);
let total_allocated_points_pre_fees = final_points;
let net_points = final_points - fee_points;
// ...
}This introduces a gap where increasing the performance fee directly results in an unintended financial benefit for the owner. Because the new rate applies when submit_period_points(...) is called, assuming the performance fee is increased, this forces the historical points batch to yield a substantially larger fee for the treasury, which were for the users.
As a result, increasing the performance fee results in dilution of user rewards.
Recommendation
Consider the implementation to make the new fee effective from future period and not immediately.
Resolution
Fixed by removing the mutable set_performance_fees admin setter. VT performance fees are now deployment-time configuration.
| File | Fix commit | Type | Status |
src/vt/contract.cairo | — | Medium | Acknowledged |
During the fee withdrawal request registration in request_withdrawal() the artificial shares are added to the total_withdraw_requested_shares_global, effectively increasing the cumulative_end_shares in the watermark check during request processing:
if request.cumulative_end_shares > self._get_processable_shares_watermark() {
return Option::None;
}The purpose of the check is to protect against the immediate withdrawals after deposit. User's shares increase the left side during request creation, and the right side after deposit lock period pass or after withdrawal request is fulfilled. However, the artificial shares do not increase the right side in case of deposit lock passing, because they are never created. At the time of the check the artificial shares only increase left side, extending the deposit lock period.
Recommendation
Consider excluding the artificial fee shares from the deposit lock period mechanism.
Resolution
Acknowledged. Fee withdrawal requests are owner-created, and the owner will only request when the withdrawal queue has sufficient capacity.
| File | Fix commit | Type | Status |
src/vt/contract.cairo | bfc6408 | Low | Fixed |
The owner fees are tracked differently depending on where they are stored. During every _update_nav() they are stored in accrued_performance_fees_claimable as USD value:
if fee_rate > 0 && total_share_supply > 0 {
let fee_delta = fee_rate
* (now - last_nav_update_time).into()
* total_share_supply
/ SCALE;
self
.accrued_performance_fees_claimable
.write(self.accrued_performance_fees_claimable.read() + fee_delta);However, when the owner creates the fee withdrawal request, the fees are stored as the shares during the queue:
if user == self.ownable.owner() {
let requested_assets = preview_assets(@self, shares);
let total_fees = self.accrued_performance_fees_claimable.read();
assert(total_fees >= requested_assets, 'Not enough fees');
self.accrued_performance_fees_claimable.write(total_fees - requested_assets);On withdrawal processing the shares are converted to value with the increase captured during this waiting period. This leads to incorrect behaviour, since the increase of stored fees is dependent on where the fees are stored. The whole stored value of shares and fees should together represent the value deposited to Wildcat, where the funds are not split between shares value and fees.
Recommendation
Consider unifying the fees growth across the protocol to match the growth of funds deposited to Wildcat.
Resolution
Fixed by tracking NAV performance fee entitlement in share units and debiting owner fee withdrawal requests from that share bucket.
| File | Fix commit | Type | Status |
src/vault/contract.cairo | bfc6408 | Low | Fixed |
The OperatorVault contract establishes an access control hierarchy mapping operational privileges to specific addresses. The depositor address handles underlying capital movements, such as sourcing USDC transfers via standard token allowances, while the operator address manages specialized active execution strategies. Administrative control over structural parameters is maintained through the OpenZeppelin two-step ownership component.
However, the contract makes the depositor and operator addresses entirely immutable after deployment. The values are initialized during contract construction and can only be accessed via public getter functions.
// ...
#[constructor]
fn constructor(
ref self: ContractState,
depositor: ContractAddress,
operator: ContractAddress,
owner: ContractAddress,
usdc: ContractAddress,
core_contract: ContractAddress,
) {
// ...
self.ownable.initializer(owner);
// @audit-issue The roles are set once here and have no corresponding setter functions.
self.depositor.write(depositor);
self.operator.write(operator);
// ...
}Because OperatorVault does not provide an y setter function for its administrative roles, the system is exposed to an operational risk. If a private key associated with an operator or depositor is compromised, the project owner has no direct, on-chain method to revoke or rotate the vulnerable key.
Recommendation
Consider adding owner-restricted set_depositor(...) and set_operator(...) functions to allow rotation of address when needed.
Resolution
Fixed by adding owner-only depositor and operator rotation mechanisms.
| File | Fix commit | Type | Status |
src/core/contract.cairo | bfc6408 | Low | Fixed |
The _create_order(...) function from the Core contract allows users to create and register new orders within the protocol. This functionality is designed to validate several parameters before persisting an order, such as checking that the caller address is non-zero, ensuring the chosen strategy is active and unmatured, and verifying that the specified apr is greater than 0. When a valid buy order is processed, the contract triggers an asset locking mechanism to secure the required tokens from the caller.
However, the _create_order(...) function does not validate whether the order.size parameter is greater than 0. This omission allows an caller to permissionlessly create an order with an order.size of 0.
fn _create_order(ref self: ContractState, order: OrderParams) -> Order {
let caller = starknet::get_caller_address();
assert!(caller.is_non_zero(), "Caller address is zero");
assert!(order.apr > 0, "APR must be greater than zero");
// @audit-issue The order.size parameter is never validated to be greater than 0.
self.assert_apr_within_max_order_apr_bps(order.apr);
let strategy = self
.assert_active_unmatured_strategy(
order.strategy_id, starknet::get_block_timestamp(),
);
let order_id = self.next_order_id.read();
self.next_order_id.write(order_id + 1);
// ...
if order.side == Side::BUY {
// @audit A size of 0 passes through the lock function without requiring allowance.
ILockingDispatcher { contract_address: strategy.vtoken }
.lock(caller, order.size);
} else {
// ...
}
}Once created, these zero-size orders cannot be removed from the system because the cancel_order(...) function requires the remaining order size to be strictly greater than 0:
fn cancel_order(...) {
// ...
let matched = order.matched_amount.unwrap_or_default();
// @audit-issue When size is 0, remaining equals 0, causing the assertion to revert.
let remaining = order.size - matched;
assert!(remaining > 0, "Order has no remaining amount");
}Recommendation
Consider adding an explicit validation check at the beginning of the _create_order(...) function to ensure that order.size is strictly greater than 0.
Resolution
Fixed by rejecting zero-size orders before they can be inserted into the order book.
| File | Fix commit | Type | Status |
src/vt/contract.cairo | — | Low | Acknowledged |
The _get_withdrawable_supply(...) function from the VaultToken contract restricts how quickly fresh deposits can be withdrawn by calculating a global lock window based on recent daily inflows. This mechanism ensures users cannot deposit and immediately withdraw funds.
However, the set_deposit_lock_days(...) function from the VaultToken contract allows the owner to change the deposit_lock_days parameter dynamically without performing any validation against the existing historical state. Because the _get_withdrawable_supply(...) function relies on the deposit_lock_days at the moment of execution, any modification applies retroactively to all historic daily inflows still recorded in the system.
In contrast, the set_withdraw_batch_delay(...) function from the VaultToken contract, implements an explicit guards to prevent modifications while pending withdrawals exist.
The dynamic computation can be observed in the _get_withdrawable_supply(...) function:
fn _get_withdrawable_supply(self: @ContractState) -> u256 {
let total_supply = self.erc20.total_supply();
let current_day = get_block_timestamp() / SECONDS_PER_DAY;
// @audit Parameter is read dynamically and retroactively applied.
let deposit_lock_days = self.deposit_lock_days.read();
if deposit_lock_days == Bounded::MAX {
return total_supply;
}
//...
}This means that an owner can retroactively extend the lock parameters of capital already committed under a shorter promised lock period. For example, if the initial lock duration was 1 day and is suddenly updated to 30 days, users who deposited capital under the original terms will find their deposits trapped for up to an extra 29 days if withdrawal requests approach the newly locked portion of the supply.
Recommendation
Consider adding a validation check within the set_deposit_lock_days(...) function to ensure that the parameter cannot be increased if active inflows from recent days are still present in the affected window.
Resolution
Acknowledged. We generally do not intend to update deposit lock days, but we want to retain the ability to do so when required by a market maker.
| File | Fix commit | Type | Status |
src/vt/contract.cairo | bfc6408 | Low | Fixed |
The request_withdrawal() function subtracts the requested fee amount from the accrued_performance_fees_claimable global variable:
if user == self.ownable.owner() {
let requested_assets = preview_assets(@self, shares);
let total_fees = self.accrued_performance_fees_claimable.read();
assert(total_fees >= requested_assets, "Not enough fees");
self.accrued_performance_fees_claimable.write(total_fees - requested_assets);However, the value in accrued_performance_fees_claimable may be stale because of lack of call to _update_nav() during which this variable is increased by accrued fees value. During call by the owner, the revert may happen even if the amount of expected fees should be withdrawable. To get the correct amount the call to request_withdrawal() must be preceded by the update_nav(). This behaviour is different when user calls request_withdrawal(), since in that case there is no need to call update_nav() separately.
Recommendation
Consider refactoring the withdrawal mechanism to not being depended on calls to another public functions depending on who is calling. The possible solution would include call to _update_nav() in the first branch if user == self.ownable.owner().
Resolution
Fixed by checkpointing NAV before owner fee withdrawal requests read and debit accrued_performance_fees_claimable.
| File | Fix commit | Type | Status |
src/vt/contract.cairo | bfc6408 | Low | Fixed |
The VaultToken contract allows users to claim accumulated points through the claim_all_points(...) function. This function identifies the point token address and attempts to transfer the allocated points to the receiver. However, the function calls transfer(...) on the IERC20Dispatcher without verifying the boolean return value.
According to the IERC20 standard, the transfer(...) function returns a boolean indicating whether the operation was successful. Some token implementations return false instead of reverting when a transfer fails. In such a case, the VaultToken contract would proceed as if the points were successfully distributed, leading to an inconsistent state where internal credits are cleared but the user received no tokens.
let pt = IERC20Dispatcher { contract_address: self.point_token.read() };
if points > 0 {
// @audit-issue The return value of transfer is not checked.
pt.transfer(receiver, points);
}Similar issue is present in the redeem_withdrawal(...) function within the same contract, where the asset.transfer(...) call is made without asserting the result.
// @audit-issue The return value of transfer is not checked.
asset.transfer(caller, request.usdc_owed);This lack of validation deviates from the defensive patterns used in other parts of the codebase, such as in EPTToken, where point transfers are explicitly asserted to ensure correctness.
Recommendation
Consider wrapping the transfer(...) calls in an assert!(...) statement to ensure the transaction reverts if the token contract returns false. This ensures consistency with the IERC20 standard and prevents silent failures.
Resolution
Fixed by checking ERC20 transfer return values when claiming points.
| File | Fix commit | Type | Status |
src/vault/contract.cairo | bfc6408 | Low | Fixed |
The OperatorVault contract is designed to facilitate split custody between different entities. According to the system design outlined in OperatorVault contract , the depositoris responsible for funding USDC while the operator handles the wrapping of assets. This operational separation is enforced throughout the contract via various access control predicates such as assert_depositor(...), assert_operator_or_depositor(...), and ownable.assert_only_owner().
> OperatorVault: depositor funds USDC; operator wraps to strategy vToken, manages > // withdrawals/points, and submits orders on core. Access is split between depositor-only > // and operator-only entry points.
However, the constructor(...) only validates that the provided addresses for these roles are non-zero. It does not check if the addresses are distinct from one another.
fn constructor(
ref self: ContractState,
depositor: ContractAddress,
operator: ContractAddress,
owner: ContractAddress,
// ...
) {
// @audit-issue These checks ensure addresses are non-zero but do not enforce uniqueness.
assert!(depositor.is_non_zero(), "Depositor is zero");
assert!(operator.is_non_zero(), "Operator is zero");
assert!(owner.is_non_zero(), "Owner is zero");
// ...
self.ownable.initializer(owner);
self.depositor.write(depositor);
self.operator.write(operator);
// ...
}This means that a single address can be assigned all three roles during deployment. While the contract remains functionally consistent, this bypasses the intended security model of segregated duties. If the single key holding all roles is compromised, the impact is significantly increased as the attacker would gain simultaneous control over capital provision, day-to-day operations, and administrative functions like pausing or upgrading the vault.
Recommendation
Consider implementing explicit checks in the constructor(...) to ensure that depositor, operator,and owner are unique addresses if the protocol requires segregated roles.
Resolution
Fixed by enforcing role separation between the depositor, operator, and owner in the OperatorVault constructor.
| File | Fix commit | Type | Status |
solidity/src/VaultReceiver.sol | bfc6408 | Low | Fixed |
The VaultReceiver contract handles Cross-Chain Transfer Protocol (CCTP) messages via two entrypoints depending on message finality. The handleReceiveUnfinalizedMessage(...) function is utilized for unfinalized messages, while the handleReceiveFinalizedMessage(...) function is utilized for finalized ones. Both functions route their execution flow internally to the _handleWithdrawRequest(...) function to complete the withdrawal logic. However, only the unfinalized entrypoint applies the nonReentrant modifier. The finalized execution path lacks this guard, creating an inconsistent defense-in-depth hardening gap across two code paths that perform identical state changes.
// ...
function handleReceiveFinalizedMessage(
uint32 sourceDomain,
bytes32 sender,
uint32, /* finalityThresholdExecuted */
bytes calldata messageBody
)
external
whenNotPaused
returns (bool)
{
_validateMessageSender(sourceDomain, sender);
return _handleWithdrawRequest(messageBody);
}
function handleReceiveUnfinalizedMessage(
uint32 sourceDomain,
bytes32 sender,
uint32, /* finalityThresholdExecuted */
bytes calldata messageBody
) external whenNotPaused nonReentrant returns (bool) {
_validateMessageSender(sourceDomain, sender);
return _handleWithdrawRequest(messageBody);
}
// ...The underlying issue is that the internal _handleWithdrawRequest(...) function makes an external call to an external market contract to queue the withdrawal. Following this external interaction, the function updates the state of the withdrawBatches mapping.
Recommendation
Consider adding nonReentrant protection to handleReceiveFinalizedMessage
Resolution
Fixed by adding nonReentrant protection to handleReceiveFinalizedMessage.
| File | Fix commit | Type | Status |
src/core/contract.cairo | bfc6408 | Low | Fixed |
The Core contract provides the mint_st_ept(...) function, which allows users to decompose vToken into ST and EPT tokens. This mechanism is a core part of the protocol's lifecycle, intended to be used while a strategy is active and before it reaches its maturity date.
However, the mint_st_ept(...) function does not check if the strategy has already passed its end_time. While the order matching function correctly enforces that strategies must be active and not matured through the _create_order(...)/execute_match(...) function, a user can bypass these restrictions by interacting with the Core contract directly.
let strategy = self.assert_active_unmatured_strategy(strategy_id, matching_time_u64);This creates an inconsistency where ST and EPT legs can be generated even after the strategy is matured. A user can lock vTokens to mint ST and EPT, then immediately use the redeem_matured_st(...) function to burn the newly minted ST and retrieve their vTokens.
fn mint_st_ept(
ref self: ContractState, strategy_id: felt252, amount: u256, recipient: ContractAddress,
) {
self.assert_not_paused();
self.reentrancy_guard.start();
// @audit-issue This function does not check if the strategy has matured.
==> let strategy = self.assert_strategy_exists(strategy_id);
let caller = starknet::get_caller_address();
ILockingDispatcher { contract_address: strategy.vtoken }.lock(caller, amount);
self._mint_st_ept(strategy, caller, amount, recipient);
self.reentrancy_guard.end();
}After strategy is matured, a user can decompose his vToken into ST and EPT tokens 100 vToken -> 100 ST + 100 EPT.
Then call redeem_matured_st to burn ST tokens and claim back 100 vToken. The user will have 100 EPT left with him.
By performing this round-trip, the user effectively extracts a new EPT leg after the strategy's expiration leading to an unexpected increase in the EPT supply post-maturity.
Recommendation
Consider adding a requirement to the mint_st_ept(...) and burn_st_ept(...) functions to ensure that the current time is less than the strategy.end_time
Resolution
Fixed by preventing mint_st_ept and burn_st_ept after strategy maturity.
| File | Fix commit | Type | Status |
src/core/contract.cairo | bfc6408 | Low | Fixed |
The core contract enforces a strict 1:1 supply parity between ST and EPT for strategy either through composition or decomposition or Order matching. This invariant is enforced by the assert_strategy_collateralization(...) function, which is called at the end of most state-changing operations like minting, burning, and trade settlement.
While the trade settlement is only applicable until end_time of the strategy, similar enforcement is not applicable to composition or decomposition strategy.
However, once a strategy reaches maturity, users can call redeem_matured_st(...). This function burns ST to return the underlying VT principal to the user but does not burn the corresponding EPT.
function redeem_matured_st(...) external {
// ...
// @audit-issue ST is burned here, reducing st_supply
st_token.lock(owner, amount);
st_token.burn_from_using_lock(owner, amount);
// @audit EPT supply remains unchanged, causing a mismatch
// ...
}Because st_supply decreases while ept_supply remains constant, the first redemption of ST immediately breaks the equality check in assert_strategy_collateralization(...).
function assert_strategy_collateralization(...) internal view {
let st_supply = ERC20ABIDispatcher { contract_address: st_token }.total_supply();
let ept_supply = ERC20ABIDispatcher { contract_address: ept_token }.total_supply();
// @audit-issue This assert will fail after any ST redemption
assert!(st_supply == ept_supply, "ST and EPT supply mismatch");
// ...
}This results in a "bricked" state for the strategy. Any subsequent call to mint_st_ept(...), burn_st_ept(...) functions that trigger the collateralization check will revert. This prevents any further decomposition or recomposition of the token legs and could potentially block other users from performing legitimate actions if those actions rely on the same validation logic.
Resolution
Fixed by preventing mint_st_ept and burn_st_ept after strategy maturity.
| File | Fix commit | Type | Status |
src/vt/contract.cairosrc/vt/withdrawal_nft.cair0 | — | Low | Acknowledged |
The VaultToken contract utilizes an NFT receipt system to manage user withdrawals. When a user initiates a withdrawal, the protocol processes the request via the _try_process_withdrawal(...) function and mints a WithdrawalNFT to request.user to track their claim. Later, when the protocol is funded, the owner of the NFT must call redeem_withdrawal(...) to claim their USDC, which internally triggers a burn mechanism to destroy the receipt token.
// ...
let nft = IWithdrawalNFTDispatcher { contract_address: self.withdrawal_nft.read() };
// @audit-issue This internal call initiates a standard mint without checking receiver compatibility.
nft.mint_withdrawal(request.user, request_id);
// ...However, the mint_withdrawal(...) function in the WithdrawalNFT contract performs a standard mint(...) instead of a safe minting operation. Standard minting does not verify whether the recipient address supports ERC721 tokens. If the withdrawal creator is a smart contract that does not implement the IERC721Receiver interface, the token will be successfully minted but completely trapped inside the receiving contract.
fn mint_withdrawal(ref self: ContractState, to: ContractAddress, token_id: u256) {
assert(get_caller_address() == self.vt_address.read(), "only VT");
// @audit-issue The contract uses standard mint instead of safe_mint.
self.erc721.mint(to, token_id);
}Consequently, the protocol cannot execute the required burn(...) operation on the NFT, creating a deadlock where the underlying USDC remains permanently stuck in the vault.
fn redeem_withdrawal(ref self: ContractState, withdrawal_id: u256) {
// ...
// @audit The caller must be the exact owner of the NFT.
assert(nft.owner_of(withdrawal_id) == caller, "not NFT owner");
// ...
let nft = IWithdrawalNFTDispatcher { contract_address: self.withdrawal_nft.read() };
nft.burn_withdrawal(withdrawal_id);
//...
asset.transfer(caller, request.usdc_owed);
// ...
}Recommendation
Considering implementing a check to ensure the depositing address supports ERC721Receiver interface.
Resolution
It was fixed initially but we decided to revert it at the end because there was an additional risk of an attacker using non supported contracts and abusing the FIFO queue.
| File | Fix commit | Type | Status |
src/core/contract.cairo | — | Info | Acknowledged |
The ArcxCore contract handles system-level pauses independently from its associated Strategy Tokens (ST) and Epoch Points Tokens (EPT). The core protocol utilizes separate administrative functions, pause_st_ept(...) and unpause_st_ept(...), to invoke the respective pause() and unpause() entry points on the individual child token contracts.
Because the system allows these assets to be managed independently, an asymmetric pause state can occur where one token is paused while the other remains active.
In the below function, only one token address is passed.
fn pause_st_ept(ref self: ContractState, token: ContractAddress) {
self.ownable.assert_only_owner();
assert!(token.is_non_zero(), "Token is zero");
self.reentrancy_guard.start();
starknet::syscalls::call_contract_syscall(token, selector!("pause"), array![].span())
.unwrap_syscall();
self.reentrancy_guard.end();
}
fn unpause_st_ept(ref self: ContractState, token: ContractAddress) {
self.ownable.assert_only_owner();
assert!(token.is_non_zero(), "Token is zero");
self.reentrancy_guard.start();
starknet::syscalls::call_contract_syscall(token, selector!("unpause"), array![].span())
.unwrap_syscall();
self.reentrancy_guard.end();
}If EPT is paused while ST remains unpaused, all paired operations such as mint_st_ept(...) and burn_st_ept(...) will atomically revert due to the balance updates failing on the EPT leg.
This does not introduce any direct impact.
Recommendation
Consider establishing an operational guideline to ensure that ST and EPT tokens are paused and unpaused atomically as a single unit per strategy identifier.
Resolution
Acknowledged. We intentionally preserve the ability to pause a single token during suspicious activity while allowing unaffected token transfers to continue.
| File | Fix commit | Type | Status |
src/core/contract.cairo | bfc6408 | Info | Fixed |
The redeem_points(...) function from the Core contract manages and distributes points earned by a strategy. When processing a distribution, the Core contract reads the current fee configuration from the EPTToken contract using the get_claim_fee_basis_points(...) function. This value is used to calculate the cumulative lifetime targets for both fees, total_fees_should_be_charged and net funding total_net_should_be_funded based on the global gross points earned.
// ...
let fee_bps = ept.get_claim_fee_basis_points();
=> let total_fees_should_be_charged = total_earned * fee_bps / BASIS_POINTS;
=> let total_net_should_be_funded = total_earned - total_fees_should_be_charged;
let fees_charged_so_far = self.ept_claim_fees_charged.read(ept_address);
// @audit-issue If the fee decreases, this cumulative check will fail.
assert!(
total_fees_should_be_charged >= fees_charged_so_far,
"Claim fee accounting regressed",
);
// ...
let points_funded_so_far = self.ept_points_funded.read(ept_address);
// @audit-issue If the fee increases, this cumulative check will fail.
assert!(
total_net_should_be_funded >= points_funded_so_far,
"EPT funding accounting regressed",
);
// ...However, the EPTToken contract also implements the set_claim_fee_basis_points(...) function, which allows the fee configuration to be updated dynamically by the Core contract.
// ...
fn set_claim_fee_basis_points(ref self: ContractState, claim_fee_basis_points: u256) {
self.assert_not_paused();
assert!(get_caller_address() == self.core_address.read(), "EPT: only core");
assert!(claim_fee_basis_points <= BASIS_POINTS, "EPT: fee exceeds 100%");
// @audit-issue The fee can be modified even after points have been redeemed.
self.claim_fee_basis_points.write(claim_fee_basis_points);
}If the fee configuration is modified after the redeem_points(...) function has already been executed at least once, it will brick the contract's accounting logic.
Depending on the direction of the fee modification, the recomputed cumulative lifetime targets will shift backward compared to the historical values already committed to storage, triggering an irreversible denial of service:
points_funded_so_far. This causes an immediate panic on the "EPT funding accounting regressed" assertion;fees_charged_so_far. This causes an immediate panic on the "Claim fee accounting regressed" assertion;Because the before_update(...) hook automatically triggers a call to redeem_points(...) during every token transfer, mint, or burn operation within the EPTToken contract, a failure in this validation step completely freezes all token liquidity. Furthermore, users will be unable to call claim_all_points(...) to collect rewards, leading to a lockup of funds. While the Core contract does not currently expose an external path to call set_claim_fee_basis_points(...), any future upgrade adding this capability will immediately introduce this vulnerability.
Recommendation
Consider removing the set_claim_fee_basis_points(...) function from the EPTToken contract if fees are intended to remain immutable after strategy deployment. If fee adjustments are required, consider updating the accounting mechanism to handle dynamic fee modifications.
Resolution
Fixed by removing the mutable set_claim_fee_basis_points setter. The EPT claim fee is now fixed at token creation.
| File | Fix commit | Type | Status |
src/vault/contract.cairo | bfc6408 | Info | Fixed |
While the natspec says, depositor is responsibke for moving USDC funds and operator is responsible for other operations, the implementation assigns depositor and operator for operations.
fn wrap_to_vtoken_and_place_order(ref self: ContractState, order: OrderRequest) -> u64 {
//...
self.assert_operator_or_depositor();
}> // OperatorVault: depositor funds USDC; operator wraps to strategy vToken, manages > // withdrawals/points, and submits orders on core. Access is split between depositor-only > // and operator-only entry points.
Recommendation
Considering correcting the Natspecs.
Resolution
Fixed by aligning the NatSpec comments with the actual OperatorVault behavior.
| File | Fix commit | Type | Status |
src/registry/contract.cairo | bfc6408 | Info | Fixed |
The add_vt(...) function in the ArcxRegistry contract is used by the owner to deploy and register a new VToken and its associated withdrawal NFT. During this process, the function records the newly deployed VToken details into the vtokens storage map.
However, the function contains two consecutive calls to self.vtokens.write(...) using the same vtoken address as the key and logically identical VToken struct data. The only difference between the two calls is the order in which the fields are initialized in the struct literal.
// ...
// @audit The first storage write occurs here.
self
.vtokens
.write(
vtoken,
VToken {
address: vtoken, exchange_id: params.exchange_id, market: params.market,
},
);
// @audit-issue The second storage write is redundant as it overwrites the first with the same data.
self
.vtokens
.write(
vtoken,
VToken {
exchange_id: params.exchange_id, market: params.market, address: vtoken,
},
);
// ...Since both writes target the same storage slot with the same values, the second write is idempotent and serves no functional purpose. This redundancy increases the contract's complexity and can be confusing for developers or auditors reviewing the logic.
Recommendation
Consider removing one of the redundant self.vtokens.write(...) blocks to keep the code clean and maintainable.
Resolution
Fixed by removing the redundant add_vt storage write.
| File | Fix commit | Type | Status |
src/core/contract.cairo | bfc6408 | Info | Fixed |
Description: The create_market_and_match(...) function from the Core contract allows users to create a new market and execute order matching. When submitting an order, users provide a quoted_apr alongside a max_slippage parameter denominated in basis points to establish acceptable boundaries for execution price movements.
However, the function fails to validate that the user-supplied max_slippage is constrained to a reasonable maximum threshold or capped at BASIS_POINTS. When an exceptionally large max_slippage value is supplied, the resulting slippage_delta calculation scales proportionally and can exceed the quoted_apr value itself. This shifts the execution boundaries to their absolute extremes, completely disabling the user's slippage safeguards.
Solidity function createMarketAndMatch(...) external // ... // @audit-issue The max_slippage parameter is not validated against BASIS_POINTS. uint256 slippageDelta = (quotedApr * order.max_slippage) / BASIS_POINTS;
if (asset == Asset.ST && side == Side.BUY) if (quotedApr > slippageDelta) worstAcceptableApr = quotedApr - slippageDelta; else worstAcceptableApr = 1;
else worstAcceptableApr = min(maxOrderAprBps, quotedApr + slippageDelta);
// ...
If slippageDelta exceeds quotedApr, then the execution logic falls into the inner else branch and sets worstAcceptableApr to 1 (representing a 0.01% APR). This completely removes the price protection for the transaction, forcing the user to accept a highly disadvantageous trade where they receive significantly fewer tokens than anticipated.
Recommendation
Consider introducing an explicit validation check to verify that the max_slippage parameter does not exceed BASIS_POINTS.
Resolution
Fixed by rejecting max_slippage values above BASIS_POINTS.
| File | Fix commit | Type | Status |
src/core/contract.cairo | bfc6408 | Info | Fixed |
The update_limit_apr(...) function is used by users to update the APR of their existing orders. However, the function does not check the current status of the order before allowing the modification.
Recommendation
Consider introducing a validation check ensuring that the order status is strictly set to either OrderStatus::Open or OrderStatus::Partially_filled before performing the update.
Resolution
Fixed by allowing APR updates only for Open or Partially_filled orders.
| File | Fix commit | Type | Status |
src/core/contract.cairo | bfc6408 | Info | Fixed |
The Core contract initializes and manages strategic layouts for tracking various token mappings. However, the storage variable responsible for tracking these strategies contains a typographical error. It is defined as vtoken_stragies instead of vtoken_strategies. This misspelled storage name is subsequently utilized when accessing or updating state within the contract functions.
// ...
// @audit-issue The storage variable name is misspelled.
vtoken_stragies: Map<ContractAddress, VTokenStrategies>,
// ...The state variable is later read from and written to across different parts of the functionality. For instance, when registering new tokens or executing queries, the codebase uses the misspelled identifier to resolve the underlying storage path components.
While this does not break the inner operational logic, but the variable name is consistently referenced in all active internal storage locations. Starknet storage slots are directly derived using the field identifier string. Altering this string in a future upgrade without careful manual state migrations will cause the protocol to lose references to historical storage slots.
Recommendation
Consider renaming vtoken_stragies to vtoken_strategies to ensure alignment with standard project naming conventions and to keep the codebase maintainable.
Resolution
Fixed by renaming vtoken_stragies to vtoken_strategies.
| File | Fix commit | Type | Status |
src/vt/contract.cairo | bfc6408 | Best Practices | Fixed |
The VaultToken contract utilizes the OpenZeppelin OwnableComponent to manage contract ownership and restricted operations. Privileged functions within the IVaultTokenAdmin interface enforce access control by calling self.ownable.assert_only_owner() or verifying the authorized operator address.
However, the contract declares an admin variable within its Storage struct that is completely unused.
// ...
struct Storage {
performance_fees: PerformanceFees,
accrued_performance_fees_claimable: u256,
// @audit-issue This variable is never initialized, read, or written to in the logic.
admin: ContractAddress,
operator: ContractAddress,
bridge_proxy: ContractAddress,
treasury: ContractAddress,
}
// ...The admin variable is never assigned a value in the constructor, is not exposed via any public getters or setters, and is never referenced anywhere else in the repository. Keeping dead storage slots increases confusion and risks storage alignment issues in future contract upgrades if the variable is modified or removed.
Recommendation
Consider removing the admin variable from the Storage struct prior to production deployment to clean up the contract layout.
Resolution
Fixed by removing the unused VT admin storage variable.
| File | Fix commit | Type | Status |
src/vt/contract.cairo | — | Best Practices | Acknowledged |
The receive_withdraw_funds(...) function from the VaultToken contract is designed to pull asynchronously returned withdrawal funds into the vault after a bridge withdrawal is requested. When an operator triggers a liquidity rebalance, the intransit_funds variable is increased, and a withdrawal request is sent to the bridge proxy. Once the bridge processes the transfer, it is expected to call receive_withdraw_funds(...) to transfer the USDC tokens back to the vault, converting the in-transit ledger balance into claimable funds for users.
However, the receive_withdraw_funds(...) function does not validate that the caller is the authorized bridge proxy contract. It only verifies that the contract is not paused, satisfies the reentrancy guard, and successfully executes transfer_from(...) from the caller to the vault.
fn receive_withdraw_funds(ref self: ContractState, amount: u256) {
self.assert_not_paused();
self.reentrancy_guard.start();
assert(amount > 0, "zero amount");
let asset = IERC20Dispatcher { contract_address: self.erc4626.ERC4626_asset.read() };
let this = get_contract_address();
// @audit-issue Any caller can invoke this function as long as they provide the allowance
let caller = get_caller_address();
let balance_before = asset.balance_of(this);
assert(asset.transfer_from(caller, this, amount), "pull failed");
}This lack of access control allows any external account or contract to execute permissionless settlement of the in-transit ledger. This action clears the intransit_funds state and prematurely increments total_withdraw_claimable_usdc_global. When the authentic bridge transaction is subsequently executed, the settled_amount calculation evaluates to 0 because intransit_funds has already been reduced to 0.
// ...
let intransit_funds = self.intransit_funds.read();
let settled_amount = min(amount, intransit_funds);
let surplus_amount = amount - settled_amount;
self.intransit_funds.write(intransit_funds - settled_amount);
self
.total_withdraw_claimable_usdc_global
.write(self.total_withdraw_claimable_usdc_global.read() + settled_amount);
if surplus_amount > 0 {
// @audit The authentic bridge return will be fully classified as surplus and sent here
assert(
asset.transfer(self.treasury.read(), surplus_amount),
"treasury transfer failed",
);
}
}Consequently, the entire amount of canonical bridge return funds is treated as a surplus and redirected to the treasury address. These is no impact and funds received from the caller of receive_withdraw_funds is treated as donation. But, as this function is clearly nominated to be called by bridge proxy, it is a best practice to restrict the caller to entitled address.
Recommendation
Consider adding an explicit access control check to ensure that only the authorized bridge proxy address can successfully call the function.
assert(get_caller_address() == self.bridge_proxy.read(), "only bridge");Resolution
Acknowledged. This is intentional so the vault can be manually funded if bridge-side settlement has an issue.
| File | Fix commit | Type | Status |
src/vault/contract.cairo | bfc6408 | Best Practices | Fixed |
The OperatorVault contract defines an event struct named PointTokenRedeemed to track the redemption of point tokens.
#[derive(Drop, starknet::Event)]
struct PointTokenRedeemed {
token: ContractAddress,
shares_in: u256,
assets_out: u256,
}But, it was never used in the contract. It is considered best practice to remove unused structures and dead code to maintain a clean, readable, and maintainable codebase.
Recommendation
Consider removing the PointTokenRedeemed struct from the contract if it is no longer needed.
Resolution
Fixed by removing the unused PointTokenRedeemed event struct.
| File | Fix commit | Type | Status |
src/bridge/contract.cairo | bfc6408 | Best Practices | Acknowledged |
The receive_and_return(...) function from the BridgeProxy contract handles incoming cross-chain messages verified by Cross-Chain Transfer Protocol (CCTP). When parsing a withdrawal completion action, the function extracts transaction parameters from the message hook_data payload to complete the withdrawal and route funds.
However, the function extracts the _sent_amount variable from byte offset 65 of the payload but leaves it completely unused. The contract relies strictly on the token balance delta before and after execution to determine the authoritative amount of tokens minted.
Considering the protocol is using the standard mode, the fee applicable is 0. As a result, the _sent_amount should be equal to delta balance after execution.
Recommendation
Consider adding an assert for _sent_amount to be equal the delta amount.
Resolution
Acknowledged. This is kept to ensure fund flow continues if we use a token w/ a different CCTP fee structure down the line.
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 Karnot Arcx team provided documentation detailing the full 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.
| 526Tests 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