Prepared by:
HALBORN
Last Updated 12/09/2024
Date of Engagement by: November 20th, 2024 - December 2nd, 2024
100% of all REPORTED Findings have been addressed
All findings
10
Critical
0
High
0
Medium
4
Low
3
Informational
3
Genius
engaged Halborn
to conduct a security assessment on their smart contracts revisions started on November 20th, 2024 and ending on December 2nd, 2024. The security assessment was scoped to the smart contracts provided to the Halborn
team.
Commit hashes and further details can be found in the Scope section of this report.
The team at Halborn was provided 1week and 4 days for the engagement and assigned a security engineer to evaluate the security of the smart contract.
The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.
The purpose of this assessment is to:
Ensure that smart contract functions operate as intended.
Identify potential security issues with the smart contracts.
In summary, Halborn identified some improvements to reduce the likelihood and impact of risks, which were mostly addressed by the Genius team
:
Add stablecoins missing balance check on ProxyCall contract.
Add initialization calls of OpenZeppelin contracts.
Add slippage checks.
Review rebalancing ratio to allow stakers to unstake when they want.
Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy regarding the scope of this assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance code coverage and quickly identify items that do not follow the security best practices. The following phases and associated tools were used during the assessment:
Research into architecture and purpose.
Smart contract manual code review and walkthrough.
Graphing out functionality and contract logic/connectivity/functions. (solgraph,draw.io
)
Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes.
Manual testing by custom scripts.
Static Analysis of security for scoped contract, and imported functions. (Slither
)
Testnet deployment. ( Hardhat
,Foundry
)
EXPLOITABILITY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
0
High
0
Medium
4
Low
3
Informational
3
Security analysis | Risk level | Remediation Date |
---|---|---|
Residual stablecoin Theft in ProxyCall Contract Due to Missing Balance Check | Medium | Solved - 12/05/2024 |
Missing Initializer Calls in GeniusVaultCore's Initialization Function | Medium | Solved - 12/05/2024 |
Insufficient Slippage Protection in swapToStables() Function | Medium | Solved - 12/05/2024 |
Locked User Funds Due to Incorrect Rebalancing Threshold Implementation | Medium | Risk Accepted - 12/09/2024 |
Lack of Fee Token/Amount Validation Enables Non-Standard Fee Collection | Low | Risk Accepted - 12/09/2024 |
Improper Native Token Address Handling for Cross-Chain Compatibility | Low | Solved - 12/05/2024 |
Nonce Increment After External Interactions Violates CEI Pattern | Low | Solved - 12/05/2024 |
Centralization Risks in Protocol's Access Control Design | Informational | Acknowledged - 12/09/2024 |
Unused Code Elements Create Unnecessary gas spent | Informational | Solved - 12/05/2024 |
Static Price Feed Decimals Configuration Limits Oracle Integration Flexibility | Informational | Solved - 12/05/2024 |
// Medium
When executing a fillOrder
through GeniusVaultCore, STABLECOIN
tokens are transferred to ProxyCall
before executing a swap using call
function :
// GeniusVaultCore.sol
STABLECOIN.safeTransfer(address(PROXYCALL), order.amountIn - order.fee);
(effectiveTokenOut, effectiveAmountOut, success) = PROXYCALL.call(
receiver,
swapTarget,
callTarget,
address(STABLECOIN),
address(tokenOut),
order.minAmountOut,
swapData,
callData
);
However, in ProxyCall.call()
(and in approveTokenExecuteAndVerify()
), only the tokenOut
balance is checked and returned to the receiver at the end of the function, not the tokenIn
(stablecoin) balance
// ProxyCall.sol
uint256 balance = IERC20(tokenOut).balanceOf(address(this));
if (balance > 0) {
IERC20(tokenOut).safeTransfer(receiver, balance);
}
// Missing check for STABLECOIN balance
When a DEX like Uniswap only needs a portion of the input tokens to provide the requested output, the unused stablecoin (tokenOut
) tokens remain in ProxyCall. These tokens are vulnerable to theft since any determined malicious user can call ProxyCall.execute()
to transfer them out via GeniusGasTank.sol, GeniusRouter.sol or a GeniusVault with this data, for example :
// Attack
bytes memory drainData = abi.encodeWithSelector(
STABLECOIN.transfer.selector,
attacker,
STABLECOIN.balanceOf(address(PROXYCALL))
);
PROXYCALL.execute(address(STABLECOIN), drainData);
Any residual STABLECOIN
tokens in ProxyCall after a swap are permanently lost or stolen, leading to direct financial losses for users.
//E forge test --match-test "testResidualStablecoinInProxyCall2" -vv
function testResidualStablecoinInProxyCall2() public {
deal(address(USDC), address(VAULT), 1_000 ether);
// Create mock DEX that will only use part of the tokens
MockDEXRouter dex = new MockDEXRouter();
deal(address(USDC), address(dex), 1_000 ether);
deal(address(WETH), address(dex), 1_000 ether);
order = IGeniusVault.Order({
seed: keccak256("order"),
amountIn: 1_000 ether,
trader: VAULT.addressToBytes32(TRADER),
receiver: RECEIVER,
srcChainId: 42,
destChainId: uint16(block.chainid),
tokenIn: VAULT.addressToBytes32(address(USDC)),
fee: 1 ether,
minAmountOut: 100 ether,
tokenOut: VAULT.addressToBytes32(address(WETH))
});
// Swap data that only uses 100 USDC => MOCK A swapExactTokensForTokens usage
bytes memory swapData = abi.encodeWithSelector(
dex.swap2.selector,
address(USDC),
address(WETH),
999 ether,
RECEIVER
);
vm.startPrank(address(ORCHESTRATOR));
VAULT.fillOrder(order, address(dex), swapData, address(0), "");
// Show how attacker can drain
vm.stopPrank();
console2.log(" ----- Attacker can drain residual USDC in ProxyCall ----- ");
console2.log("[Before] USDC balance of ProxyCall = %s",USDC.balanceOf(address(PROXYCALL)));
console2.log("[Before] Alice balance = %s",USDC.balanceOf(ALICE));
bytes memory drainData = abi.encodeWithSelector(
USDC.transfer.selector,
ALICE,
USDC.balanceOf(address(PROXYCALL))
);
PROXYCALL.execute(address(USDC), drainData);
console2.log("[After] USDC balance of ProxyCall = %s",USDC.balanceOf(address(PROXYCALL)));
console2.log("[After] Alice balance = %s",USDC.balanceOf(ALICE));
console2.log(" ----- Attacker drained residual USDC in ProxyCall ----- ");
}
Modified DexRouter's swap
function :
function swap2(
address tokenIn,
address tokenOut,
uint256 amountIn,
address receiver
) external payable returns (uint256) {
// Only use a small portion of the input tokens
uint256 actualUsed = amountIn / 3; // Only use 20% of input
// Transfer the small portion back, leaving residual in the contract
IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn/2);
IERC20(tokenOut).safeTransfer(receiver, actualUsed);
return actualUsed;
}
Result :
It is recommended to add STABLECOIN
balance check and transfer them if needed in ProxyCall's call()
and approveTokenExecuteAndVerify()
functions:
uint256 balance = IERC20(tokenOut).balanceOf(address(this));
if (balance > 0) {
IERC20(tokenOut).safeTransfer(receiver, balance);
}
uint256 stablecoinBalance = IERC20(stablecoin).balanceOf(address(this));
if (stablecoinBalance > 0) {
IERC20(stablecoin).safeTransfer(receiver, stablecoinBalance);
}
SOLVED: The Shuttle Labs team added a check for stablecoinBalance
and transfer remaining assets to receiver in case there is a surplus.
// Medium
The GeniusVaultCore contract inherits from ReentrancyGuardUpgradeable
and UUPSUpgradeable
but fails to call their respective initializer functions during initialization. This omission breaks the initialization chain required for proper contract behavior.
In GeniusVaultCore.sol
:
function _initialize(
address _stablecoin,
address _admin,
address _multicall,
uint256 _rebalanceThreshold,
address _priceFeed
) internal onlyInitializing {
if (_stablecoin == address(0)) revert GeniusErrors.NonAddress0();
if (_admin == address(0)) revert GeniusErrors.NonAddress0();
__ERC20_init("Genius USD", "gUSD");
__AccessControl_init();
__Pausable_init();
// Missing critical initializer calls:// __ReentrancyGuard_init();// __UUPSUpgradeable_init();
}
The __ReentrancyGuard_init()
should initialize the ReentrancyGuard storage :
function __ReentrancyGuard_init() internal onlyInitializing {
__ReentrancyGuard_init_unchained();
}
function __ReentrancyGuard_init_unchained() internal onlyInitializing {
ReentrancyGuardStorage storage $ = _getReentrancyGuardStorage();
$._status = NOT_ENTERED;
}
The ReentrancyGuard's internal status variable remains uninitialized, breaking the reentrancy protection mechanism. This directly impacts all functions using the nonReentrant
modifier in the contract because the uninitialized
ReentrancyGuard means the status variable remains at its default value (0), rather than being set to NOT_ENTERED
(1). This effectively breaks all reentrancy protection in the contract. An attacker can perform reentrancy attacks on any function marked with the nonReentrant
modifier since the guard's state checks will fail silently.
The inheritance chain initialization is incomplete, leading to a broken contract state.
This test can be added to GeniusVault.t.sol
:
//E forge test --match-test testSetup -vv
function testSetup() public {
console2.log("Reentrancy Status after setup = %s",VAULT.MockedReentrancyGuardView());
console2.log("\t---> should be equal to 1");
assertEq(VAULT.MockedReentrancyGuardView(),1,"Reentrancy guard should be 1");
}
And this function to ReentrancyGuardUpgradeable.sol
:
function MockedReentrancyGuardView() public view returns (uint256) {
ReentrancyGuardStorage storage $ = _getReentrancyGuardStorage();
return $._status;
}
Here is the result :
It is recommended to add the missing initializer calls in the _initialize
function:
function _initialize(
address _stablecoin,
address _admin,
address _multicall,
uint256 _rebalanceThreshold,
address _priceFeed
) internal onlyInitializing {
if (_stablecoin == address(0)) revert GeniusErrors.NonAddress0();
if (_admin == address(0)) revert GeniusErrors.NonAddress0();
__ERC20_init("Genius USD", "gUSD");
__AccessControl_init();
__Pausable_init();
__ReentrancyGuard_init();// Add this line
__UUPSUpgradeable_init();// Add this line
SOLVED: Initializers are now triggered within the contracts.
// Medium
The swapToStables()
function in GeniusMultiTokenVault.sol implements a weak slippage control mechanism that accepts any positive increase in balance after a swap, regardless of how minimal the increase is:
function swapToStables(
address token,
uint256 amount,
address target,
bytes calldata data
) external override onlyOrchestrator whenNotPaused {
uint256 preSwapBalance = stablecoinBalance();
if (token == NATIVE) {
PROXYCALL.execute{value: amount}(target, data);
} else {
IERC20(token).safeTransfer(address(PROXYCALL), amount);
PROXYCALL.approveTokenExecute(token, target, data);
}
uint256 postSwapBalance = stablecoinBalance();
if (postSwapBalance <= preSwapBalance) revert GeniusErrors.TransferFailed(token, amount);
The issue lies in the comparison that only checks if postSwapBalance <= preSwapBalance
. This permits swaps that return as little as 1 wei more than the initial balance to pass the validation.
MEV searchers can extract value from the protocol by:
Front-running swapToStables() transactions
Executing sandwich attacks on the swap
Returning the minimal amount required (preSwapBalance + 1) to pass the check
It also open doors for Orchestrator roles to drain the vault from any token token
in exchange of 1 wei of stablecoin. The economic loss is bounded by the value of tokens being swapped minus 1 wei of stablecoin.
It is recommened to implement a proper slippage protection by adding a minAmountOut
parameter and enforcing it:
function swapToStables(
address token,
uint256 amount,
uint256 minAmountOut,
address target,
bytes calldata data
) external override onlyOrchestrator whenNotPaused {
// ... existing checks ...
uint256 preSwapBalance = stablecoinBalance();
// ... execute swap ...
uint256 postSwapBalance = stablecoinBalance();
uint256 amountReceived = postSwapBalance - preSwapBalance;
if (amountReceived < minAmountOut) {
revert GeniusErrors.InvalidAmountOut(amountReceived, minAmountOut);
}
}
SOLVED : A minAmountOut
has been added to swapToStables()
function.
// Medium
The liquidity calculation logic within GeniusVaultCore's rebalancing mechanism can lead to user withdrawals being permanently blocked when maximum rebalancing occurs.
The issue stems from how minLiquidity()
is calculated in relation to availableAssets()
. When rebalancing takes place, it can consume all available liquidity without accounting for user withdrawal rights.
Relevant code:
// In GeniusVaultCore.sol
function availableAssets() public view returns (uint256) {
uint256 _totalAssets = stablecoinBalance();
uint256 _neededLiquidity = minLiquidity();
return _availableAssets(_totalAssets, _neededLiquidity);
}
function _availableAssets(
uint256 _totalAssets,
uint256 _neededLiquidity
) internal pure returns (uint256) {
if (_totalAssets < _neededLiquidity) {
return 0;
}
return _totalAssets - _neededLiquidity;
}
// In GeniusVault.sol (but same in GeniusMultiTokenVault.sol
function minLiquidity() public view override returns (uint256) {
uint256 _totalStaked = totalStakedAssets;
uint256 reduction = (_totalStaked * rebalanceThreshold) / 10_000;
uint256 minBalance = _totalStaked - reduction;
return minBalance + claimableFees();
}
When rebalanceThreshold
is set to 7500 (75%), and rebalanceLiquidity()
is called with the maximum available amount, there is not enough funds for users to withdraw their stakes.
The same is true for fillOrder()
function, meaning that an order can be filled with staked amounts from stakers.
This test can be added to GeniusVault.t.sol
:
The issue is reproducible through the following sequence:
Users stake tokens (e.g., 200 USDC total)
Orchestrator rebalances 150 USDC (75% of total)
Remaining 50 USDC matches minimum required balance
All withdrawal attempts fail with InvalidAmount
error
//E forge test --match-test "testStakeAndRebalance" -vv
function testStakeAndRebalance() public {
// Initial stakes from two users
vm.startPrank(ALICE);
deal(address(USDC), ALICE, 100 ether);
USDC.approve(address(VAULT), 100 ether);
VAULT.stakeDeposit(100 ether, ALICE);
vm.stopPrank();
vm.startPrank(BOB);
deal(address(USDC), BOB, 100 ether);
USDC.approve(address(VAULT), 100 ether);
VAULT.stakeDeposit(100 ether, BOB);
vm.stopPrank();
// Verify initial state
assertEq(VAULT.stablecoinBalance(), 200 ether, "Total vault balance should be 200 ether");
assertEq(VAULT.totalStakedAssets(), 200 ether, "Total staked assets should be 200 ether");
// Due to the 75% rebalanceThreshold (7_500 basis points), available assets should be 150 ether
assertEq(VAULT.availableAssets(), 150 ether, "Available assets should be 150 ether");
console2.log(" ----- Staking part done ----- ");
// Setup rebalancing
address bridgeAddress = makeAddr("bridge");
uint256 amountToRebalance = 150 ether;
bytes memory bridgeData = abi.encodeWithSelector(
USDC.transfer.selector,
bridgeAddress,
amountToRebalance
);
// Execute rebalancing at the maximum amount possible
vm.startPrank(ORCHESTRATOR);
VAULT.rebalanceLiquidity(
amountToRebalance,
destChainId,
address(USDC),
bridgeData
);
vm.stopPrank();
// Verify final state
assertEq(VAULT.stablecoinBalance(), 50 ether, "Vault should have 100 ether remaining");
assertEq(USDC.balanceOf(bridgeAddress), 150 ether, "Bridge should have received 100 ether");
assertEq(VAULT.totalStakedAssets(), 200 ether, "Total staked assets should remain 200 ether");
// Available assets = current balance - min required (200 * 0.25)
assertEq(VAULT.availableAssets(), 0 ether, "Available assets should be 50 ether");
console2.log(" ----- Rebalancing part done ----- ");
console2.log("USDC amount available in the vault = %s",USDC.balanceOf(address(VAULT)));
console2.log("Amount staked by Alice and Bob = %s",VAULT.totalStakedAssets());
console2.log("\n ----- Alice and Bob try to withdraw ");
vm.startPrank(ALICE);
VAULT.approve(address(VAULT), 100 ether);
VAULT.stakeWithdraw(100 ether, address(ALICE), address(ALICE));
vm.stopPrank();
console2.log(" Alice could withdraw 100 ether");
vm.startPrank(BOB);
VAULT.approve(address(VAULT), 100 ether);
VAULT.stakeWithdraw(100 ether, address(BOB), address(BOB));
vm.stopPrank();
console2.log(" Bob could withdraw 100 ether");
}
This reverts because there is not enough funds in the contract:
It is recommended to have a rebalanceThreshold
which allows staker to still withdraw their stakes when they want.
RISK ACCEPTED: Rebalancing action will handle this risk off-chain but no on chain requirement will be implemented.
// Low
In GeniusGasTank.sol, the sponsored transaction functions accept arbitrary fee tokens and amounts without proper validation mechanisms:
function sponsorOrderedTransactions(
address target,
bytes calldata data,
IAllowanceTransfer.PermitBatch calldata permitBatch,
bytes calldata permitSignature,
address owner,
address feeToken, //E @audit arbitrary
uint256 feeAmount, //E @audit arbitrary
uint256 deadline,
bytes calldata signature
) external payable override whenNotPaused {
// ... //
IERC20(feeToken).safeTransfer(feeRecipient, feeAmount);
// ... //
}
function sponsorUnorderedTransactions(
address target,
bytes calldata data,
IAllowanceTransfer.PermitBatch calldata permitBatch,
bytes calldata permitSignature,
address owner,
address feeToken, //E @audit arbitrary
uint256 feeAmount, //E @audit arbitrary
uint256 deadline,
bytes32 seed,
bytes calldata signature
) external payable override whenNotPaused {
// ... //
IERC20(feeToken).safeTransfer(feeRecipient, feeAmount);
// ... //
}
In the Genius protocol, sponsored transactions allow users to define some parameters in order to "motivate" a sponsor to create a transaction for them.
However, since sponsors may not be the feeRecipient
receiving fees for the sponsored transaction, the "incentivization" of higher fees = higher chance of having transactions sponsored is null, rendering the motivation for users who want to be sponsored to increase the fees of the transaction then increasing the chance that a sponsored transaction include 0 fee.
The lack of validation on fee tokens and amounts results in:
Non-standardized fee collection across the protocol
Acceptance of any ERC20 token as valid payment
Absence of minimum/maximum fee boundaries
Inconsistent economic incentives for transaction processing
It is recommended to implement strict fee validation by:
Adding a whitelist for accepted fee tokens.
Enforcing minimum and maximum fee amounts per token.
RISK ACCEPTED: The Shuttle Labs team accept the risk, as the transaction needs to be attractive (with good fees) to be validated and sponsored.
// Low
In GeniusMultiTokenVault.sol, the contract hardcodes the native token address as address(0)
, which creates incompatibility issues with certain L2 networks like zkSync where ETH has a specific contract address.
contract GeniusMultiTokenVault is IGeniusMultiTokenVault, GeniusVaultCore {
using SafeERC20 for IERC20;
// .... //
address public immutable NATIVE = address(0);
This assumption breaks on networks like zkSync where ETH resides at 0x000000000000000000000000000000000000800A
. The contract uses this NATIVE
constant to identify the native token in several functions, including token balance checks and swap operations:
function tokenBalance(address token) public view returns (uint256) {
if (token == NATIVE) {
return address(this).balance;
} else {
return IERC20(token).balanceOf(address(this));
}
}
Impact:
Native token operations fail on L2 networks where ETH has a non-zero address
Token balance checks return incorrect values for ETH
It is recommended to replace the hardcoded NATIVE
address with a constructor parameter or implement a chain-specific configuration.
SOLVED: NATIVE
is now an argument of the initialize()
function.
// Low
The GeniusGasTank contract's sponsorOrderedTransactions
function increments the user's nonce only at the end of the function during event emission, after performing external contract interactions. This implementation violates the Checks-Effects-Interactions
pattern:
function sponsorOrderedTransactions(
address target,
bytes calldata data,
IAllowanceTransfer.PermitBatch calldata permitBatch,
bytes calldata permitSignature,
address owner,
address feeToken,
uint256 feeAmount,
uint256 deadline,
bytes calldata signature
) external payable override whenNotPaused {
if (deadline < block.timestamp) revert GeniusErrors.DeadlinePassed(deadline);
bytes32 messageHash = keccak256(abi.encode(target,data,permitBatch,nonces[owner],feeToken,feeAmount,deadline,address(this)));
_verifySignature(messageHash, signature, owner);
address[] memory tokensIn = _permitAndBatchTransfer(
permitBatch,
permitSignature,
owner,
feeToken,
feeAmount
);
IERC20(feeToken).safeTransfer(feeRecipient, feeAmount);
if (target == address(PROXYCALL)) PROXYCALL.execute{value: msg.value}(target, data);
else {
PROXYCALL.approveTokensAndExecute{value: msg.value}(
tokensIn,
target,
data
);
}
emit OrderedTransactionsSponsored(msg.sender,owner,target,feeToken,feeAmount,nonces[owner]++);
}
The nonce increment occurs after multiple external interactions, including token transfers and proxy calls.
Impact:
State changes occur after external contract calls
The execution order breaks smart contract security best practices
Creates an unnecessary reentrancy vector despite the overall safety of the implementation
It is recommended to move the nonce increment before any external interactions:
function sponsorOrderedTransactions(
// ... parameters ...
) external payable override whenNotPaused {
if (deadline < block.timestamp) revert GeniusErrors.DeadlinePassed(deadline);
bytes32 messageHash = keccak256(abi.encode(target,data,permitBatch,nonces[owner],feeToken,feeAmount,deadline,address(this)));
_verifySignature(messageHash, signature, owner);
uint256 currentNonce = nonces[owner];
nonces[owner] = currentNonce + 1;// Increment nonce early
address[] memory tokensIn = _permitAndBatchTransfer(
permitBatch,
permitSignature,
owner,
feeToken,
feeAmount
);
// ... rest of the function ...
emit OrderedTransactionsSponsored(msg.sender,owner,target,feeToken,feeAmount,currentNonce);
}
SOLVED: Nonce is now incremented after the verification of the signature.
// Informational
The protocol implementation grants excessive privileges to administrative roles, enabling fund extraction through multiple vectors. This centralization manifests in three critical functions across the protocol's contracts:
In GeniusVaultCore.sol, the fillOrder
function allows unrestricted execution by orchestrators:
function fillOrder(
Order memory order,
address swapTarget,
bytes memory swapData,
address callTarget,
bytes memory callData
) external virtual override nonReentrant onlyOrchestrator whenNotPaused {
// No limit on fund movement through swap operations
In GeniusVaultCore.sol, rebalanceLiquidity
provides access to substantial fund movement:
function rebalanceLiquidity(
uint256 amountIn,
uint256 dstChainId,
address target,
bytes calldata data
) external payable virtual override onlyOrchestrator whenNotPaused {
if (target == address(0)) revert GeniusErrors.NonAddress0();
_isAmountValid(amountIn, availableAssets());
// Can drain up to rebalanceThreshold
In GeniusMultiTokenVault.sol, swapToStables
enables orchestrator-controlled token drainage:
function swapToStables(
address token,
uint256 amount,
address target,
bytes calldata data
) external override onlyOrchestrator whenNotPaused {
// Can drain non-stablecoin tokens by manipulating output validation
In GeniusVault.sol, withdrawFunds
:
function withdrawFunds() external onlyAdmin {
uint256 balance = STABLECOIN.balanceOf(address(this));
STABLECOIN.safeTransfer(msg.sender, balance);
}
Impact:
The admin/orchestrator roles possess unilateral control over user funds
No time-locks or multi-signature requirements protect high-risk operations
A single compromised admin account can lead to complete protocol drainage
Users must place complete trust in the protocol administrators
It is recommended to add value limits for sensitive operations done by admins, implement multi-signature requirements.
ACKNOWLEDGED: The Shuttle Labs team acknowledge the issue and will switch to a governance as soon as possible.
// Informational
The protocol contains several unused elements across multiple contracts that increase contract size and complicate code readability without providing functional value.
In GeniusVaultCore.sol, the _updateStakedBalance
function exists but is never called:
function _updateStakedBalance(
uint256 amount,
uint256 add
) internal {
if (add == 1) { totalStakedAssets += amount; }
else { totalStakedAssets -= amount; }
}
The supportedBridges
mapping in GeniusVaultCore.sol is declared but never utilized:
mapping(address => uint256) public supportedBridges;
In GeniusActions.sol, the authorizedOrchestrators
mapping remains unused throughout the contract:
mapping(address => bool) internal authorizedOrchestrators;
Impact:
Increased deployment gas costs due to unnecessary bytecode
Reduced code maintainability and clarity
Higher complexity during security audits and code reviews
Misleading state variables suggesting unimplemented functionality
It is recommended to remove all unused code elements to improve contract efficiency and readability.
SOLVED: Unused code has been removed.
// Informational
The GeniusVaultCore contract hardcodes price bounds with an assumed 8 decimals precision, even if it's the default behavior of Chainlink price feeds, it is not guaranteed that in the future price feeds use the same decimal configurations:
// Price bounds (8 decimals like Chainlink)
uint256 public constant PRICE_LOWER_BOUND = 98_000_000;// 0.98
uint256 public constant PRICE_UPPER_BOUND = 102_000_000;// 1.02
The price verification function uses these static bounds without accounting for the actual decimals returned by the price feed:
function _verifyStablecoinPrice() internal view returns (bool) {
try stablecoinPriceFeed.latestRoundData() returns (
uint80 roundId,
int256 price,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
) {
uint256 priceUint = uint256(price);
if (priceUint < PRICE_LOWER_BOUND || priceUint > PRICE_UPPER_BOUND) {
revert GeniusErrors.PriceOutOfBounds(priceUint);
}
Impact:
Integration breaks with price feeds using non-8 decimal configurations
Manual deployment adjustments needed for different oracle implementations
Reduced protocol flexibility when integrating with new price feeds
It is recommended to initialize price bounds dynamically based on the price feed's decimals configuration:
contract GeniusVaultCore {
uint256 public immutable PRICE_LOWER_BOUND;
uint256 public immutable PRICE_UPPER_BOUND;
function _initialize(
address _stablecoin,
address _admin,
address _multicall,
uint256 _rebalanceThreshold,
address _priceFeed
) internal onlyInitializing {
uint8 decimals = AggregatorV3Interface(_priceFeed).decimals();
uint256 base = 10 ** decimals;
PRICE_LOWER_BOUND = (98 * base) / 100;// 0.98 with proper decimals
PRICE_UPPER_BOUND = (102 * base) / 100;// 1.02 with proper decimals
// Rest of initialization...
}
}
SOLVED: Price feeds can now be configured in initialize()
function.
Halborn used automated testing techniques to enhance the coverage of certain areas of the smart contracts in scope. Among the tools used was Slither
, a Solidity static analysis framework.
After Halborn verified the smart contracts in the repository and was able to compile them correctly into their abis and binary format, Slither was run against the contracts. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.
All issues identified by Slither
were proved to be false positives or have been added to the issue list in this report.
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.
// Download the full report
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed