Reach out for an audit or to learn more about Macro
or Message on Telegram

Seven Seas A-50

Security Audit

June 30, 2025

Version 1.0.0

Presented by 0xMacro

Table of Contents

Introduction

This document includes the results of the security audit for Seven Seas's smart contract code as found in the section titled ‘Source Code’. The security audit performed by the Macro security team over multiple days starting June 16th-20th, 2025.

The purpose of this audit is to review the source code of certain Seven Seas Solidity contracts, and provide feedback on the design, architecture, and quality of the source code with an emphasis on validating the correctness and security of the software in its entirety.

Disclaimer: While Macro’s review is comprehensive and has surfaced some changes that should be made to the source code, this audit should not solely be relied upon for security, as no single audit is guaranteed to catch all possible bugs.

Overall Assessment

The following is an aggregation of issues found by the Macro Audit team:

Severity Count Acknowledged Won't Do Addressed
High 2 - - 2
Medium 2 - - 2
Low 4 1 - 3
Code Quality 3 - - 3
Gas Optimization 3 2 - 1

Seven Seas was quick to respond to these issues.

Specification

Our understanding of the specification was based on the following sources:

Trusted Entities:

Solver: Executes users signed intents to deposit or withdraw from the vault. They are trusted to execute these orders in a timely manner.

Strategist: Can call rebalance() to adjust assets in the vault. Allows them to swap tokens via accepted aggregators or directly with the corresponding uniswapV4 pool, as well as provide or remove liquidity. A portion of the profits from these rebalances, typically via rewards earned by providing liquidity, are distributed to the set payout address. It is trusted that they act in the best interest of the protocol, and to not manipulate the vault to extract profit.

Admin: Can set the rebalance deviations, performance fees, payout address, the price oracle and trusted aggregators, Is trusted to set reasonable values, and set proper aggregators and price oracles. It is understood that a malicious aggregator or oracle could be used to extract value from the vault.

Multisig: Can pause and unpause the Intents teller and the flux manager, preventing deposits, withdrawals or rebalancing. They are trusted to only pause these contracts when necessary.

Owner: Can set deposit and withdrawal asset data, share lock period and intent max deadlines, as well as control permissioned operators as well as allowing and denying transfers for select users. They are trusted to set proper values that benefit the protocol, as well as only freeze transfers of those that are adversely effecting the protocol or its users.

Denier: Can allow or deny users and operators to transfer shares. Like the owner is trusted to only freeze a users assets when necessary and in the interest of the protocol.

Source Code

The following source code was reviewed during the audit:

Note: This document contains an audit solely of the Solidity contracts listed above. Specifically, the audit pertains only to the contracts themselves, and does not pertain to any other programs or scripts, including deployment scripts.

Issue Descriptions and Recommendations

Click on an issue to jump to it, or scroll down to see them all.

Security Level Reference

We quantify issues in three parts:

  1. The high/medium/low/spec-breaking impact of the issue:
    • How bad things can get (for a vulnerability)
    • The significance of an improvement (for a code quality issue)
    • The amount of gas saved (for a gas optimization)
  2. The high/medium/low likelihood of the issue:
    • How likely is the issue to occur (for a vulnerability)
  3. The overall critical/high/medium/low severity of the issue.

This third part – the severity level – is a summary of how much consideration the client should give to fixing the issue. We assign severity according to the table of guidelines below:

Severity Description
(C-x)
Critical

We recommend the client must fix the issue, no matter what, because not fixing would mean significant funds/assets WILL be lost.

(H-x)
High

We recommend the client must address the issue, no matter what, because not fixing would be very bad, or some funds/assets will be lost, or the code’s behavior is against the provided spec.

(M-x)
Medium

We recommend the client to seriously consider fixing the issue, as the implications of not fixing the issue are severe enough to impact the project significantly, albiet not in an existential manner.

(L-x)
Low

The risk is small, unlikely, or may not relevant to the project in a meaningful way.

Whether or not the project wants to develop a fix is up to the goals and needs of the project.

(Q-x)
Code Quality

The issue identified does not pose any obvious risk, but fixing could improve overall code quality, on-chain composability, developer ergonomics, or even certain aspects of protocol design.

(I-x)
Informational

Warnings and things to keep in mind when operating the protocol. No immediate action required.

(G-x)
Gas Optimizations

The presented optimization suggestion would save an amount of gas significant enough, in our opinion, to be worth the development cost of implementing it.

Issue Details

H-1

Shares may not be locked for receiver on deposit

Topic
Protocol Design
Status
Impact
High
Likelihood
High

When a deposit intent is fulfilled by the solver, the user that signed the intent pays the required assets, and a shareLock period is set for this user if the share locking is enforced:

if (enforceShareLock) {
    _afterPublicDeposit(depositData.user, depositData.asset, depositData.amountIn, shares, shareLockPeriod);

Reference: IntentsTeller.sol#L546-547

function _afterPublicDeposit(
    address user,
    ERC20 depositAsset,
    uint256 depositAmount,
    uint256 shares,
    uint256 currentShareLockPeriod
) internal {
    // Increment then assign as its slightly more gas efficient.
    uint256 nonce = ++depositNonce;
    // Only set share unlock time and history if share lock period is greater than 0.
    if (currentShareLockPeriod > 0) {
        beforeTransferData[user].shareUnlockTime = uint64(block.timestamp + currentShareLockPeriod);
        publicDepositHistory[nonce] = keccak256(
            abi.encode(user, depositAsset, depositAmount, shares, block.timestamp, currentShareLockPeriod)
        );
    }
    emit Deposit(nonce, user, address(depositAsset), depositAmount, shares, block.timestamp, currentShareLockPeriod);
}

Reference: IntentsTeller#L590-607

When a users shares are locked, it prevents shares from being transferred until the period has elapsed. The intent of this is to prevent users from withdrawing immediately and potentially take advantage of arbitrage opportunities, or take short term profit in volatile events of price swings.

However, a deposit does not always result in the users receiving the shares, as they can specify a to address which is the receiver when calling boringVault.enter():

vault.enter(depositData.user, depositData.asset, depositData.amountIn, depositData.to, shares);

Reference: IntentsTeller.sol#544

This means that if the user and the receiving to address differ, the receiver is able to bypass the share lock period and thus able to freely transfer and withdraw immediately after receiving their assets.

Remediations to Consider

Do not allow a receiving address to be specified, and send shares to the user instead. Otherwise if you allow a specified receiver and lock their shares as intended it opens up griefing vectors.

H-2

Native token operations will consistently fail

Topic
DOS
Status
Impact
High
Likelihood
High

The IntentsTeller contract is designed to facilitate deposits and withdrawals for token0 and token1 as defined by its associated fluxManager. However, if token0 is configured to be the native token (represented by address(0)), all deposit and withdrawal operations for this asset will fail.

The root cause of this issue lies within the _erc20Deposit() and _erc20Withdraw() functions in IntentsTeller contract. These functions directly pass the asset's address from ActionData to the enter() and exit() functions of the BoringVault contract.

The BoringVault contract's enter() and exit() methods are designed to work exclusively with ERC20 tokens. They execute asset.safeTransferFrom() and asset.safeTransfer() respectively. When depositData.asset or withdrawData.asset is address(0), these calls will revert because address(0) is not a contract and has no functions to call.

The highest impact scenario occurs when the contract is deployed with the native token as one of the core assets in the liquidity pool. This would render the primary functionality of depositing and withdrawing that native asset completely inoperable.

Remediations to Consider

To support native assets, the logic in _erc20Deposit() and _erc20Withdraw() should be updated. When depositData.asset corresponds to the native token, the contract should instead use its wrapped ERC20 counterpart (e.g., WETH) for interactions with the BoringVault.

        vault.enter(
                depositData.user, 
-				depositData.asset, 
+				depositData.asset == address(0) ? nativeWrapper : depositData.asset,
                depositData.amountIn, 
                depositData.to, 
                shares
        );
            
        vault.exit(
                withdrawData.to, 
-				withdrawData.asset, 
+				withdrawData.asset == address(0) ? nativeWrapper : withdrawData.asset, 
                assetsOut, 
                withdrawData.user, 
                withdrawData.amountIn
        );
M-1

Signed intents can be used for either deposit or withdrawals

Topic
Signatures
Status
Impact
High
Likelihood
Low

ActionData is signed by users to define their intent of depositing or withdrawing and their acceptable parameters. This is then used by a address with the Solver permission to call either deposit() or withdraw(). Each validates that intents data user signed it, and hasn’t been used prior, before executing it. Notably the value isWithdrawal is signed and verified in _verifySignedMessage(), however it is not used to check if the intended function deposit() or withdraw() is executing either. This allows for the solver to ignore the isWithdrawal intent and call either function. Typically the Solver is trusted to execute these intents as intended, if a solver were to call the wrong function it would likely be detrimental to the user.

Remediations to Consider

Validate the intended function, deposit() or withdraw() is called based on the isWithdrawal value.

M-2

Incorrect asset denomination in fee claiming leads to fund misappropriation

Topic
Fee Mechanism
Status
Impact
High
Likelihood
Low

There is a discrepancy between how performance fees are calculated and claimed. The pendingFee is calculated in rebalance() based on the immutable baseIn0Or1 flag, which determines whether the fee is denominated in token0 or token1. However, the claimFees() function takes a token0Or1 parameter that allows the fee to be paid out in an asset independent of how it was calculated. This allows an admin to withdraw fees in the wrong asset, leading to a misinterpretation of value due to differing token decimals and prices. As the result, admin may mistakenly claim more or less value as fee than expected. Moreover, in extreme case that admin claim way more than expected, it can massively drop the price of BoringVault share.

Remediations to Consider

The claimFees() function should not accept a parameter for the token type. It should instead use the baseIn0Or1 flag to ensure fees are always claimed in the same asset they were denominated in.

L-1

Mismatched data types in function parameters may lead to unexpected behaviour

Topic
Type Mismatch
Status
Impact
Medium
Likelihood
Low

The _mint() function in the UniswapV4FluxManager contract defines parameters with data types that are inconsistent with the official Uniswap V4 documentation.

In _mint() function, the liquidity parameter is uint128 while the documentation specifies uint256. This unnecessarily restricts the amount of liquidity that can be added.

Conversely, amount0Max and amount1Max are uint256 instead of uint128. When decoding, Uniswap uses a low-level method instead of a standard abi.decode. This means that when the values are greater than type(uint128).max, instead of throwing an error, Uniswap will use the 128 least significant bits. As a result, this can lead to unexpected values for amount0Max and amount1Max.

Other functions like _burn(), _increaseLiquidity(), and _decreaseLiquidity() are also affected by similar issues.

Remediations to Consider Align the data types in the _mint(), _burn(), _increaseLiquidity(), and _decreaseLiquidity() functions with the Uniswap V4 documentation.

L-2

Unclaimed native ETH not included in initial rebalance calculations

Topic
Accounting
Status
Impact
Low
Likelihood
Low

In UniswapV4FluxManager.sol, a user with the strategist role can call rebalance() which allow for adjusting assets in the vault via actions like swapping tokens, providing or removing liquidity. As safeguard to ensure asset value is relatively the same after the call to rebalance, the value before and after is calculated and checked that the delta does not exceed set rebalance deviations. The initial token balances are refreshed before unwrapping any wETH into native ETH before calculating the total assets:

_refreshInternalFluxAccounting();
if (address(token0) == address(0)) {
    _unwrapAllNative();
}
uint256 totalSupplyBefore = boringVault.totalSupply();
uint256 totalAssetsInBaseBefore = totalAssets(exchangeRate, baseIn0Or1);

Reference: UniswapV4FluxManager.sol#L226-231

/// @notice Refresh internal flux constants.
/// @dev For Uniswap V4 this is token0 and token1 contract balances
function _refreshInternalFluxAccounting() internal override {
    token0Balance = address(token0) == address(0)
        ? SafeCast.toUint128(ERC20(nativeWrapper).balanceOf(address(boringVault)))
        : SafeCast.toUint128(token0.balanceOf(address(boringVault)));
    token1Balance = SafeCast.toUint128(token1.balanceOf(address(boringVault)));
}

Reference: UniswapV4FluxManager.sol#L179-186

However, in the case where the boring vault has a native ETH balance, it will not be included in the token balance initially calculated in _refreshInternalFluxAccounting(), it will however be included after the rebalance as all ETH is wrapped back to wETH before _refreshInternalFluxAccounting() is called and total assets calculated. This results is potentially inaccurate differences in asset value as a result of the rebalance and could effect whether the rebalance deviation is triggered. Unaccounted for native ETH could result in a rebalance that loses excessive funds to not revert as expected, or if there is an excessive amount of unaccounted for ETH, it could prevent any rebalance occurring as it would trigger the upper rebalance deviation threshold.

Remediations to Consider

In _refreshInternalFluxAccounting() account for native ETH as well as wETH as required to ensure accurate accounting.

L-3

Use nonces for Intents to prevent collision

Topic
Signatures
Status
Acknowledged
Impact
Low
Likelihood
Low

ActionData parameters are signed by users and validated in _verifySignedMessage(), where if valid it will mark the digest as used to prevent the signature from being re-used:

if (usedSignatures[digest]) {
    revert IntentsTeller__DuplicateSignature();
}

usedSignatures[digest] = true;

Reference: IntentsTeller.sol#L637-641

However, in the case where the same data is intended to be used multiple times, they will share the same digest and only one can execute. Although this case may be rare, it is good to include the possibility of multiple of the same intents.

Remediations to Consider

Include a nonce in each intent to distinguish from other intents with the same values.

L-4

Use a Hash Struct for EIP712 signatures

Topic
Specifications
Status
Impact
Low
Likelihood
Low

Currently parameters in ActionData are signed by users to agree to an intent to either deposit or withdraw into the boring vault. However, EIP712 expects a hash struct to be signed, which includes a type hash which describes the struct being signed, as well as the encoded parameters. This is the expected way to interact with the EIP712 standard to help ensure signatures can only be used for specific actions in a contract.

Remediations to Consider

Include a type hash of the data being signed.

Q-1

Redundant address in EIP-712 signed message

Topic
Redundancy
Status
Quality Impact
Low

The _verifySignedMessage() function in the IntentsTeller contract includes address(this) when constructing the EIP-712 message hash. The underlying _hashTypedDataV4() function from OpenZeppelin's EIP712 contract already incorporates the contract's address via the domain separator. Consider excluding address(this) when constructing the EIP-712 message hash to avoid redundancy.

Q-2

Redundant calculations

Topic
Redundancy
Status
Quality Impact
Low

The getRate() and totalAssets() functions in FluxManager contract contain redundant arithmetic, performing division operations where the numerator and denominator share a common factor.

  1. managers/FluxManager.sol#L217

    -		return uint256(10 ** decimals1).mulDivDown(exchangeRate, 10 ** decimals1);		
    +		return exchangeRate;
    
  2. managers/FluxManager.sol#L187-L189

    -		uint256 converted = token1Assets * (10 ** decimals0);
    -		converted = converted.mulDivDown(10 ** decimals1, exchangeRate);
    -		converted /= 10 ** decimals1;
    
    +		uint256 converted = token1Assets.mulDivDown(10 ** decimals0, exchangeRate);
    
  3. managers/FluxManager.sol#L193-L195

    -		uint256 converted = token0Assets * (10 ** decimals1);
    -		converted = converted.mulDivDown(exchangeRate, 10 ** decimals1);
    -		converted /= 10 ** decimals0;
    
    +		uint256 converted = token0Assets.mulDivDown(exchangeRate, 10 ** decimals0);
    
Q-3

Unused state variables

Topic
Redundancy
Status
Quality Impact
Low

The state variables lastPerformanceReview, performanceReviewFrequency, and totalSupplyLastReview are declared in FluxManager contract but are never utilized. Consider removing these variables will optimize gas usage and improve code clarity.

G-1

Inefficient lookup of position data using loops

Topic
Gas Optimization
Status
Acknowledged
Gas Savings
Low

The UniswapV4FluxManager contract uses loops in _removePositionIfPresent(), _incrementLiquidity(), and _decrementLiquidity() functions to find position data. As the number of tracked positions increases, the gas cost for these functions will grow, leading to higher operational costs. The use of loops for searching through an array is the root cause.

Consider replacing the loop with a mapping from positionId to its index in the trackedPositions array. This provides an O(1) lookup, significantly reducing gas costs.

G-2

Redundant data validation

Topic
Redundancy
Status
Acknowledged
Gas Savings
Low

In FluxManager contract, the getRate() function calls totalAssets() function within its execution path. Both of these functions are decorated with the checkDatum modifier. Consequently, when getRate() is called and the vault's total supply is non-zero, the checkDatum validation, which involves an external call, is performed twice. This redundancy leads to unnecessary gas consumption for core vault operations like deposits and withdrawals.

The recommended solution is to remove the checkDatum modifier from getRate and explicitly perform the validation only within the code paths where the total supply is zero. The following diff illustrates this change:

-   function getRate(uint256 exchangeRate, bool quoteIn0Or1) public view checkDatum(exchangeRate) returns (uint256) {
+   function getRate(uint256 exchangeRate, bool quoteIn0Or1) public view returns (uint256) {
        uint256 ts = boringVault.totalSupply();
        if (ts == 0) {
+		        datum.validateExchangeRateWithDatum(exchangeRate, decimals1, datumLowerBound, datumUpperBound);
            if (baseIn0Or1 && quoteIn0Or1) {
                return 10 ** decimals0;
            } else if (!baseIn0Or1 && quoteIn0Or1) {
                return uint256(10 ** decimals0).mulDivDown(10 ** decimals1, exchangeRate);
            } else if (baseIn0Or1 && !quoteIn0Or1) {
                return uint256(10 ** decimals1).mulDivDown(exchangeRate, 10 ** decimals1);
            } else if (!baseIn0Or1 && !quoteIn0Or1) {
                return 10 ** decimals1;
            } else {
                // Generic revert as we will never actually reach this branch.
                revert();
            }
        } else {
            uint256 ta = totalAssets(exchangeRate, quoteIn0Or1);
            return ta.mulDivDown(10 ** decimalsBoring, ts);
        }
    }
G-3

Redundant State Variables for Token Balances Cause Gas Inefficiency

Topic
Gas Optimization
Status
Gas Savings
Low

The state variables token0Balance and token1Balance in UniswapV4FluxManager contract cache token balances from BoringVault. These balances are immediately refreshed via _refreshInternalFluxAccounting() at the start of every function that uses them, such as rebalance() in UniswapV4FluxManager contract and deposit()/withdraw()/bulkActions() in IntentsTeller contract.

This immediate refresh treats the cached values as transient for a single transaction, making their storage in state redundant and incurring unnecessary SSTORE gas costs. This design also adds complexity by requiring developers to manually call the refresh function. Fetching balances directly when needed makes the contract simpler, less error-prone, and more gas-efficient.

Consider removing the token0Balance and token1Balance state variables and the now-obsolete _refreshInternalFluxAccounting() function. Instead, fetch balances directly from token contracts inside the functions that use them. This eliminates SSTORE costs and simplifies the logic.

-		function _refreshInternalFluxAccounting() internal override {
-		    token0Balance = address(token0) == address(0)
-		        ? SafeCast.toUint128(ERC20(nativeWrapper).balanceOf(address(boringVault)))
-		        : SafeCast.toUint128(token0.balanceOf(address(boringVault)));
-		    token1Balance = SafeCast.toUint128(token1.balanceOf(address(boringVault)));
-		}

        function _totalAssets(uint256 exchangeRate)
            internal
            view
            override
            returns (uint256 token0Assets, uint256 token1Assets)
        {
-		    token0Assets = token0Balance;
-		    token1Assets = token1Balance;
+				token0Assets = address(token0) == address(0)
+		        ? SafeCast.toUint128(ERC20(nativeWrapper).balanceOf(address(boringVault)))
+		        : SafeCast.toUint128(token0.balanceOf(address(boringVault)));
+		    token1Assets = SafeCast.toUint128(token1.balanceOf(address(boringVault)));
    
            // Calculate the current sqrtPrice.
            uint256 ratioX192 = FullMath.mulDiv(exchangeRate, 2 ** 192, 10 ** decimals0);
            uint160 sqrtPriceX96 = SafeCast.toUint160(_sqrt(ratioX192));
    
            // Iterate through tracked position data and aggregate token balances
            uint256 positionCount = trackedPositionData.length;
            for (uint256 i; i < positionCount; ++i) {
                PositionData memory data = trackedPositionData[i];
                (uint256 amount0, uint256 amount1) = LiquidityAmounts.getAmountsForLiquidity(
                    sqrtPriceX96,
                    TickMath.getSqrtRatioAtTick(data.tickLower),
                    TickMath.getSqrtRatioAtTick(data.tickUpper),
                    data.liquidity
                );
                token0Assets += amount0;
                token1Assets += amount1;
            }
        }

Disclaimer

Macro makes no warranties, either express, implied, statutory, or otherwise, with respect to the services or deliverables provided in this report, and Macro specifically disclaims all implied warranties of merchantability, fitness for a particular purpose, noninfringement and those arising from a course of dealing, usage or trade with respect thereto, and all such warranties are hereby excluded to the fullest extent permitted by law.

Macro will not be liable for any lost profits, business, contracts, revenue, goodwill, production, anticipated savings, loss of data, or costs of procurement of substitute goods or services or for any claim or demand by any other party. In no event will Macro be liable for consequential, incidental, special, indirect, or exemplary damages arising out of this agreement or any work statement, however caused and (to the fullest extent permitted by law) under any theory of liability (including negligence), even if Macro has been advised of the possibility of such damages.

The scope of this report and review is limited to a review of only the code presented by the Seven Seas team and only the source code Macro notes as being within the scope of Macro’s review within this report. This report does not include an audit of the deployment scripts used to deploy the Solidity contracts in the repository corresponding to this audit. Specifically, for the avoidance of doubt, this report does not constitute investment advice, is not intended to be relied upon as investment advice, is not an endorsement of this project or team, and it is not a guarantee as to the absolute security of the project. In this report you may through hypertext or other computer links, gain access to websites operated by persons other than Macro. Such hyperlinks are provided for your reference and convenience only, and are the exclusive responsibility of such websites’ owners. You agree that Macro is not responsible for the content or operation of such websites, and that Macro shall have no liability to your or any other person or entity for the use of third party websites. Macro assumes no responsibility for the use of third party software and shall have no liability whatsoever to any person or entity for the accuracy or completeness of any outcome generated by such software.