Security Audit
June 28, 2023
Version 1.0.0
Presented by 0xMacro
This document includes the results of the security audit for Sommelier's smart contract code as found in the section titled ‘Source Code’. The security audit was performed by the Macro security team from June 5, 2023 to June 23, 2023.
The purpose of this audit is to review the source code of certain Sommelier 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.
The following is an aggregation of issues found by the Macro Audit team:
Severity | Count | Acknowledged | Won't Do | Addressed |
---|---|---|---|---|
Medium | 4 | - | - | 4 |
Low | 1 | - | - | 1 |
Code Quality | 6 | - | - | 6 |
Informational | 3 | 1 | - | 2 |
Gas Optimization | 3 | - | - | 3 |
Sommelier was quick to respond to these issues.
Our understanding of the specification was based on the following sources:
The following source code was reviewed during the audit:
569d676eb6becd3245c0a2afe89ee34a3158790f
fb9694aa79e222af4ede3631de79c97fa5d19819
Specifically, we audited the following contracts within this repository for the initial part of the audit:
Source Code | SHA256 |
---|---|
src/AxelarProxy.sol |
|
src/modules/adaptors/Aave/AaveATokenAdaptor.sol |
|
src/modules/adaptors/Compound/CTokenAdaptor.sol |
|
src/modules/adaptors/Frax/FTokenAdaptor.sol |
|
src/modules/adaptors/Frax/FTokenAdaptorV1.sol |
|
src/modules/adaptors/Morpho/MorphoAaveV2ATokenAdaptor.sol |
|
src/modules/adaptors/Morpho/MorphoAaveV2DebtTokenAdaptor.sol |
|
src/modules/adaptors/Morpho/MorphoAaveV3ATokenCollateralAdaptor.sol |
|
src/modules/adaptors/Morpho/MorphoAaveV3ATokenP2PAdaptor.sol |
|
src/modules/adaptors/Morpho/MorphoAaveV3DebtTokenAdaptor.sol |
|
src/modules/adaptors/Morpho/MorphoRewardHandler.sol |
|
src/modules/price-router/Extensions/Balancer/BalancerPoolExtension.sol |
|
src/modules/price-router/Extensions/Balancer/BalancerStablePoolExtension.sol |
|
src/modules/price-router/Extensions/Lido/WstEthExtension.sol |
|
src/modules/price-router/PriceRouter.sol |
|
We audited the following contracts as part of the second part of the audit:
Source Code | SHA256 |
---|---|
src/modules/adaptors/Balancer/BalancerPoolAdaptor.sol |
|
src/modules/adaptors/Sommelier/CellarAdaptor.sol |
|
src/modules/price-router/Extensions/Redstone/RedstonePriceFeedExtension.sol |
|
Click on an issue to jump to it, or scroll down to see them all.
relayerJoinPool()
and relayerExitPool()
relayerExitPool()
liquidityGauge
We quantify issues in three parts:
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. |
In MorphoAaveV2ATokenAdaptor
and MorphoAaveV3ATokenCollateralAdaptor
, each allows the strategist to withdraw assets from Morpho via withdrawFromAaveV2Morpho()
, and withdrawFromAaveV3Morpho()
respectively.
function withdrawFromAaveV2Morpho(IAaveToken aToken, uint256 amountToWithdraw) public {
morpho().withdraw(address(aToken), amountToWithdraw, address(this));
}
Reference: MorphoAaveV2ATokenAdaptor.sol#L157-L158
function withdrawFromAaveV3Morpho(ERC20 tokenToWithdraw, uint256 amountToWithdraw) public {
morpho().withdrawCollateral(address(tokenToWithdraw), amountToWithdraw, address(this), address(this));
}
Reference: MorphoAaveV3ATokenCollateralAdaptor.sol#L138-L140
The MorphoAaveV2DebtTokenAdaptor
also allows the strategist to borrow assets from Morpho with borrowFromAaveV2Morpho()
.
function borrowFromAaveV2Morpho(address aToken, uint256 amountToBorrow) public {
// Check that debt position is properly set up to be tracked in the Cellar.
bytes32 positionHash = keccak256(abi.encode(identifier(), true, abi.encode(aToken)));
uint32 positionId = Cellar(address(this)).registry().getPositionHashToPositionId(positionHash);
if (!Cellar(address(this)).isPositionUsed(positionId))
revert MorphoAaveV2DebtTokenAdaptor__DebtPositionsMustBeTracked(aToken);
// Borrow from morpho.
morpho().borrow(aToken, amountToBorrow);
}
Reference: MorphoAaveV2DebtTokenAdaptor#L104-L114
However, each of these functions can decrease the health factor of the cellars Morpho position and there are no checks to ensure the health factor is maintained above a minimum safe value. This can allow a strategist to bring the health factor of the cellars position down to the liquidation threshold of Aave, causing the cellar’s Morpho positions to be at risk of liquidation.
Remediations to Consider
Check the health factor of the cellar after a strategist withdraws funds, to ensure the positions do not become too close to being liquidated.
In MorphoAaveV2ATokenAdaptor.sol
a isLiquid
is set in the positions configurationData
value that will determine if the position is allowed to be withdrawn by users using withdraw()
, this is to prevent users from withdrawing assets from a position that is used as collateral to support a debt position:
function withdraw(
uint256 assets,
address receiver,
bytes memory adaptorData,
bytes memory configData
) public override {
// Run external receiver check.
_externalReceiverCheck(receiver);
// Make sure position is setup to be liquid.
bool isLiquid = abi.decode(configData, (bool));
if (!isLiquid) revert BaseAdaptor__UserWithdrawsNotAllowed();
IAaveToken aToken = abi.decode(adaptorData, (IAaveToken));
// Withdraw assets from Morpho.
morpho().withdraw(address(aToken), assets, receiver);
}
Reference: MorphoAaveV2TokenAdaptor#L78-L95
However there is the possibility for a MorphoAaveV2ATokenAdaptor
position to be set as isLiquid
but then have the cellar later take on debt from Morpho via a MorphoAaveV2DebtTokenAdaptor
position, or for a position to be setup incorrectly by the strategist. In this case, any withdraws from the liquid Morpho lending position would effect the health factor of the debt position, potentially allowing the health factor to be manipulated by cellar share holders to allow the cellars Morpho positions to be liquidated maliciously.
Remediations to Consider
Either
Doing either of these will prevent the possibility of users maliciously effecting the health of a misconfigured cellars debt position.
relayerJoinPool()
and relayerExitPool()
In BalancerPoolAdaptor.sol
's relayerJoinPool
and relayerExitPool
, it takes a bytes
array of calldata
that is directly used to make a call on the relayer.
function relayerJoinPool(
ERC20[] memory tokensIn,
uint256[] memory amountsIn,
ERC20 bptOut,
bytes[] memory callData
) public {
for (uint256 i; i < tokensIn.length; ++i) {
tokensIn[i].approve(address(vault), amountsIn[i]);
}
uint256 startingBpt = bptOut.balanceOf(address(this));
relayer.multicall(callData);
uint256 endingBpt = bptOut.balanceOf(address(this));
uint256 amountBptOut = endingBpt - startingBpt;
PriceRouter priceRouter = Cellar(address(this)).priceRouter();
uint256 amountBptIn = priceRouter.getValues(tokensIn, amountsIn, bptOut);
if (amountBptOut < amountBptIn.mulDivDown(slippage(), 1e4)) revert BalancerPoolAdaptor___Slippage();
// revoke token in approval
for (uint256 i; i < tokensIn.length; ++i) {
_revokeExternalApproval(tokensIn[i], address(vault));
}
}
Reference: BalancerPoolAdaptor#L208-L231
function relayerExitPool(ERC20 bptIn, uint256 amountIn, ERC20[] memory tokensOut, bytes[] memory callData) public {
PriceRouter priceRouter = Cellar(address(this)).priceRouter();
uint256[] memory tokenAmount = new uint256[](tokensOut.length);
for (uint256 i; i < tokensOut.length; ++i) {
tokenAmount[i] = tokensOut[i].balanceOf(address(this));
}
relayer.multicall(callData);
for (uint256 i; i < tokensOut.length; ++i) {
tokenAmount[i] = tokensOut[i].balanceOf(address(this)) - tokenAmount[i];
}
uint256 bptEquivalent = priceRouter.getValues(tokensOut, tokenAmount, bptIn);
if (bptEquivalent < amountIn.mulDivDown(slippage(), 1e4)) revert BalancerPoolAdaptor___Slippage();
}
Reference: BalancerPoolAdaptor.sol#L243-L256
This can allow a strategist to make multiple arbitrary calls to balancer relayer and is not limited to exiting or joining a pool as intended. This opens the strategist to potentially make swaps or interact with balancer in unintended ways, like setting internal balancer balances that are not tracked by the cellar.
Remediations to Consider
Explicitly call joinPool()
or exitPool()
on the vault
contract, or with the relayer to ensure the function executes as expected.
relayerExitPool()
In BalancerPoolAdaptor.sol’s relayerExitPool()
, there are input parameters passed in to determine if tokens received from exiting the pool is within an accepted slippage range.
function relayerExitPool(ERC20 bptIn, uint256 amountIn, ERC20[] memory tokensOut, bytes[] memory callData) public {
PriceRouter priceRouter = Cellar(address(this)).priceRouter();
uint256[] memory tokenAmount = new uint256[](tokensOut.length);
for (uint256 i; i < tokensOut.length; ++i) {
tokenAmount[i] = tokensOut[i].balanceOf(address(this));
}
relayer.multicall(callData);
for (uint256 i; i < tokensOut.length; ++i) {
tokenAmount[i] = tokensOut[i].balanceOf(address(this)) - tokenAmount[i];
}
uint256 bptEquivalent = priceRouter.getValues(tokensOut, tokenAmount, bptIn);
if (bptEquivalent < amountIn.mulDivDown(slippage(), 1e4)) revert BalancerPoolAdaptor___Slippage();
}
Reference: BalancerPoolAdaptor.sol#L243-L256
However, since only the calldata
is used to make the call with the relayer, these other input parameters can be unrelated to the call itself. This allows a strategist to add empty values to amountIn
and tokensOut
, which allows the strategist to ignore the slippage check.
Remediations to Consider
Explicitly call exitPool with set input parameters on the vault
contract or ensure the calldata
sent to the relayer matches the values entered in order to properly make slippage checks.
The PriceRouter.sol’s _getPriceInUSD()
takes the price of assets and converts them to their price in USD with 8 decimals.
/**
* @notice Helper function to get an assets price in USD.
* @dev Returns price in USD with 8 decimals.
*/
function _getPriceInUSD(ERC20 asset, AssetSettings memory settings) internal view returns (uint256) {
// Call get price function using appropriate derivative.
uint256 price;
if (settings.derivative == 1) {
price = _getPriceForChainlinkDerivative(asset, settings.source);
} else if (settings.derivative == 2) {
price = _getPriceForTwapDerivative(asset, settings.source);
} else if (settings.derivative == 3) {
price = Extension(settings.source).getPriceInUSD(asset);
} else revert PriceRouter__UnknownDerivative(settings.derivative);
return price;
}
Reference: PriceRouter.sol#L515-L531
However, there are some assets supported by chainlink that their price in USD is not 8 decimals. In the case of AMPL/USD the price returned is 18 decimals. This can disrupt with the valuation of assets by either inflating or deflating the price from the actual value of the asset.
Remediations to Consider
Either
Will not support assets via chainlink if they do not have 8 decimals
In BalancerPoolExtension.sol
, there is a check to ensure the vault has not in the middle of execution, which could lead to a known read-only reentrancy issue.
function _ensureNotInVaultContext(IVault vault) internal view {
// Perform the following operation to trigger the Vault's reentrancy guard.
// Use a static call so that it can be a view function (even though the
// function is non-view).
//
// IVault.UserBalanceOp[] memory noop = new IVault.UserBalanceOp[](0);
// _vault.manageUserBalance(noop);
// solhint-disable-next-line var-name-mixedcase
bytes32 REENTRANCY_ERROR_HASH = keccak256(abi.encodeWithSignature("Error(string)", "BAL#400"));
// read-only re-entrancy protection - this call is always unsuccessful but we need to make sure
// it didn't fail due to a re-entrancy attack
// This might just look like an issue in foundry. Running a testnet test does not use an insane amount of gas.
(, bytes memory revertData) = address(vault).staticcall(
abi.encodeWithSelector(vault.manageUserBalance.selector, new address[](0))
);
if (keccak256(revertData) == REENTRANCY_ERROR_HASH) revert BalancerPoolExtension__Reentrancy();
}
Reference: BalancerPoolExtension.sol#L43-L62
However, as suggested by Balancer, static calls will consume all gas forwarded to them if they revert due to a storage modification, which is the goal of this check. Forwarding most of the available gas towards this static call, as is done here, would cause a lot additional gas to be consumed.
Additionally, it is more gas efficient to check if the reverting data’s length is greater than zero, than it is to check the returned error, as any error when static calling this function would be the reentrancy error.
Remediations to Consider
Use the code suggested by balancer to prevent excess gas consumption when making this check.
In PriceRouter.sol
’s _getValues()
, it loops though all an array of assets and amounts to determine the value of all assets.
for (uint8 i = 0; i < baseAssets.length; i++) {
// Skip zero amount values.
if (amounts[i] == 0) continue;
ERC20 baseAsset = baseAssets[i];
if (baseAsset == quoteAsset) valueInQuote += amounts[i];
else {
uint256 basePrice;
{
AssetSettings memory baseSettings = getAssetSettings[baseAsset];
if (baseSettings.derivative == 0) revert PriceRouter__UnsupportedAsset(address(baseAsset));
basePrice = _getPriceInUSD(baseAsset, baseSettings);
}
valueInQuote += _getValueInQuote(
basePrice,
quotePrice,
baseAsset.decimals(),
quoteDecimals,
amounts[i]
);
}
}
Reference: PriceRouter.sol#L585-L605
However, the index i
used in the loop is a uint8
which gets cast as a uint256
each time it is used to index from the amounts
or baseAssets
array. Each casting consumes additional gas.
Remediations to Consider
Set i
as a uint256
instead of a uint8
to prevent unnecessary casting and gas consumption.
In PriceRouter.sol
, the functions _getValues()
and getExchangeRates()
use for loops when executing.
for (uint8 i = 0; i < baseAssets.length; i++) {
Reference: PriceRouter.sol#L585
for (uint256 i; i < numOfAssets; i++) {
Reference: Pricerouter.sol#L474
However, it is 5 gas cheaper to increment these indexes using ++i
than it is to use i++
.
Although 5 gas is not much savings, over many loops it can add up, especially when these functions are called as frequently as they are.
Remediations to Consider
Replace instances of i++
with ++i
.
In MorphoAaveV3ATokenCollateralAdaptor.sol
's withdrawableFrom()
the natSpec comment states that it “Checks that cellar has no active borrows, and if so reverts.”
However, if there are active borrows, withdrawableFrom()
returns 0
rather than reverting.
/**
* @notice Checks that cellar has no active borrows, and if so reverts.
*/
function withdrawableFrom(bytes memory adaptorData, bytes memory) public view override returns (uint256) {
address[] memory borrows = morpho().userBorrows(msg.sender);
if (borrows.length > 0) return 0;
else {
ERC20 underlying = abi.decode(adaptorData, (ERC20));
return morpho().collateralBalance(address(underlying), msg.sender);
}
}
Reference: MorphoAaveV3ATokenCollateralAdaptor.sol#L82-L92
Remediations to Consider
Alter the comment to clarify that it will return 0
rather than revert.
In Lido/WstEthExtension.sol
, the IChainlinkAggregator
import is unused.
import { IChainlinkAggregator } from "src/interfaces/external/IChainlinkAggregator.sol";
Reference: WstEthExtension.sol#L5
Remediations to Consider
Remove the IChainlinkAggregator
import.
In MorphoAaveV2ATokenAdaptor.sol
, MorphoAaveV3ATokenCollateralAdaptor.sol
, and MorphoAaveV3ATokenP2PAdaptor.sol
, there are functions where adaptorData
is decoded into either an IAaveToken
or ERC20
, but only the address is used.
IAaveToken aToken = abi.decode(adaptorData, (IAaveToken));
// Withdraw assets from Morpho.
morpho().withdraw(address(aToken), assets, receiver);
Reference: MorphoAaveV2ATokenAdaptor.sol#L91-L94
ERC20 underlying = abi.decode(adaptorData, (ERC20));
// Withdraw assets from Morpho.
morpho().withdrawCollateral(address(underlying), assets, address(this), receiver);
Reference: MorphoAaveV3ATokenCollateralAdaptor.sol#L76-L79
ERC20 underlying = abi.decode(adaptorData, (ERC20));
return morpho().collateralBalance(address(underlying), msg.sender);
Reference: MorphoAaveV3ATokenCollateralAdaptor.sol#L89-L90 and MorphoAaveV3ATokenCollateralAdaptor.sol#L98-L99
ERC20 underlying = abi.decode(adaptorData, (ERC20));
uint256 iterations = abi.decode(configurationData, (uint256));
// Withdraw assets from Morpho.
morpho().withdraw(address(underlying), assets, address(this), receiver, iterations);
Reference: MorphoAaveV3ATokenP2PAdaptor.sol#L85-L89
ERC20 underlying = abi.decode(adaptorData, (ERC20));
return morpho().supplyBalance(address(underlying), msg.sender);
Reference: MorphoAaveV3ATokenP2PAdaptor.sol#L96-L97 and MorphoAaveV3ATokenP2PAdaptor.sol#L104-L105
Remediations to Consider
Cast the decoded adaptorData
as an address
where specified to prevent additional type casting.
In FTokenAdaptor.sol
there is a misspelling of override.
* and overrid the interface helper functions.
Reference: FTokenAdaptor.sol#L11
Remediations to Consider
Fix this spelling error.
liquidityGauge
In BalancerPoolAdaptor.sol
’s stakeBPT()
and unstakeBPT()
, there is an unnecessarily cast of liquidityGauge
to address
when _liquidityGauge
is already an address representing the same thing.
function stakeBPT(ERC20 _bpt, address _liquidityGauge, uint256 _amountIn) external {
_validateBptAndGauge(address(_bpt), _liquidityGauge);
uint256 amountIn = _maxAvailable(_bpt, _amountIn);
ILiquidityGaugev3Custom liquidityGauge = ILiquidityGaugev3Custom(_liquidityGauge);
_bpt.approve(address(liquidityGauge), amountIn);
liquidityGauge.deposit(amountIn, address(this));
_revokeExternalApproval(_bpt, address(liquidityGauge));
}
Reference: BalancerPoolAdaptor.sol#L266-L273
function unstakeBPT(ERC20 _bpt, address _liquidityGauge, uint256 _amountOut) public {
_validateBptAndGauge(address(_bpt), _liquidityGauge);
ILiquidityGaugev3Custom liquidityGauge = ILiquidityGaugev3Custom(_liquidityGauge);
_amountOut = _maxAvailable(ERC20(address(liquidityGauge)), _amountOut);
liquidityGauge.withdraw(_amountOut);
}
Reference: BalancerPoolAdaptor.sol#L281-L286
There is also an unnecessary casting in balanceOf()
where liquidityGauge
is already and address that is cast again to an address.
function balanceOf(bytes memory _adaptorData) public view override returns (uint256) {
(ERC20 bpt, address liquidityGauge) = abi.decode(_adaptorData, (ERC20, address));
if (liquidityGauge == address(0)) return ERC20(bpt).balanceOf(msg.sender);
ERC20 liquidityGaugeToken = ERC20(address(liquidityGauge));
uint256 stakedBPT = liquidityGaugeToken.balanceOf(msg.sender);
return ERC20(bpt).balanceOf(msg.sender) + stakedBPT;
}
Reference: BalancerPoolAdaptor.sol#L152-L158
Remediations to Consider
Use _liquidityGauge
instead of liquidityGauge
where an address is needed.
Each adaptor has comments that describe the adaptors specification. However in BalancerPoolAdaptor.sol
there are additional unnecessary Adaptor Data and Configuration Data comments.
//==================== Adaptor Data Specification ====================
// See Related Open Issues on this for BalancerPoolAdaptor.sol
//================= Configuration Data Specification =================
// NOT USED
Reference: BalancerPoolAdaptor.sol#L36-L39
Remediations to Consider
Remove these additional comments.
A assets price oracle is set by the PriceRegistry.sol
contract which it’s multisig owner can call startEditAsset()
to edit where after a 7 day delay, the new asset’s pricing will take effect.
This can allow the multisig to potentially rug cellars if the asset’s new oracle info is malicious, such as setting it to an Extension
contract oracle that could be written to return favourable pricing in specific situations.
Editing the asset can be stopped by sommelier governance, by calling transitionOwner()
to transition the owner over the course of 7 days, while preventing any owner functions from being called, preventing an asset from completing it pending change. Doing so requires governance and the community to watch for any calls to startEditAsset()
and flag anything that seems malicious. Third party bots should be setup that notify the community of calls to startEditAsset()
.
Sommelier will create guides to teach the community how they can set up OpenZeppelin Sentinel bots to watch important contracts such as the PriceRouter and Registry, and notify them of certain actions taken.
As mentioned in I-1, governance can call transitionOwner() of the PriceRouter to change the owner. This occurs over the course of 7 days, during which no owner functions can be called. In the case that governance transitions the price router’s owner to a new malicious owner, the community should be aware and respond accordingly. Third party bots should be setup that notify the community of calls to transitionOwner().
Sommelier will create guides to teach the community how they can set up OpenZeppelin Sentinel bots to watch important contracts such as the PriceRouter and Registry, and notify them of certain actions taken.
Cellars can hold positions in another cellar’s shares using the CellarAdaptor
. This can be beneficial when cellars have correlated assets and withdrawing shares from a cellar would result in receiving correlated assets to what is held in the parent cellar. There is the possibility, however, where a strategist may want to hold shares in a cellar that holds unrelated assets. Doing so could cause user withdrawals to receive multiple assets that are unrelated to the parent cellar’s positions, and users may want to sell these. To prevent this, a strategist can set a cellar position to be illiquid, preventing user withdrawals of those cellar shares, and ensuring the assets received from a user withdrawal are more in line with what is expected.
It is important to note that illiquid cellar positions will remain until a strategist withdraws them, leaving shares in the cellar unable to be withdrawn if the only remaining positions are illiquid cellar shares.
Accepted
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 Sommelier 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.