Basin - Eurovickk's results

A composable EVM-native decentralized exchange protocol.

General Information

Platform: Code4rena

Start Date: 03/07/2023

Pot Size: $40,000 USDC

Total HM: 14

Participants: 74

Period: 7 days

Judge: alcueca

Total Solo HM: 9

Id: 259

League: ETH

Basin

Findings Distribution

Researcher Performance

Rank: 70/74

Findings: 1

Award: $6.07

QA:
grade-b

🌟 Selected for report: 0

🚀 Solo Findings: 0

https://github.com/code-423n4/2023-07-basin/blob/main/src/Aquifer.sol

Take necessary precautions to secure the mapping data to prevent unauthorized modification or access. Consider implementing access control mechanisms to restrict updates to the mapping and ensure that only authorized entities can modify the data.

Access Control Modifier: Define a modifier that checks if the caller has the necessary permissions to update the mapping. This modifier can be applied to functions that modify the wellImplementations mapping.

modifier onlyAuthorized() { // Implement your authorization logic here require(msg.sender == authorizedAddress, "Unauthorized"); _; }

Authorized Address: Define a variable that represents the authorized address allowed to modify the mapping. This address should be set during contract deployment or through a separate administrative function.

address public authorizedAddress;

function setAuthorizedAddress(address _address) external onlyOwner { authorizedAddress = _address; }

Update Mapping with Access Control: Apply the onlyAuthorized modifier to the functions responsible for updating the wellImplementations mapping.

function boreWell( address implementation, bytes calldata immutableData, bytes calldata initFunctionCall, bytes32 salt ) external nonReentrant onlyAuthorized returns (address well) { // Implementation logic here // ...

// Update wellImplementations mapping wellImplementations[well] = implementation; // Emit event or perform other actions // ...

}

With these steps, only the authorized address (which can be set to a specific account or contract) will have the ability to modify the wellImplementations mapping. Other accounts or contracts will receive a "Unauthorized" revert message when attempting to call the functions that modify the mapping.

https://github.com/code-423n4/2023-07-basin/blob/main/src/Well.sol

Gas Limit Consideration: Be aware of the gas limit when performing swaps. If the gas required for the swap exceeds the block gas limit, the transaction may fail. Consider implementing mechanisms such as batch swaps or using external protocols for larger swaps.

Batch Swaps: Instead of performing individual swaps for each token pair, you can implement batch swaps where multiple token pairs are swapped in a single transaction. This reduces the overhead of multiple function calls and allows for more efficient use of gas. Batch swaps can be especially useful when dealing with a large number of tokens.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.17;

import {IERC20} from "oz/token/ERC20/IERC20.sol"; import {SafeERC20} from "oz/token/ERC20/utils/SafeERC20.sol";

contract BatchSwap { using SafeERC20 for IERC20;

struct Swap { address fromToken; address toToken; uint256 amountIn; uint256 minAmountOut; address recipient; } function performBatchSwap(Swap[] memory swaps) external { for (uint256 i = 0; i < swaps.length; i++) { Swap memory swap = swaps[i]; _swap(swap.fromToken, swap.toToken, swap.amountIn, swap.minAmountOut, swap.recipient); } } function _swap( address fromToken, address toToken, uint256 amountIn, uint256 minAmountOut, address recipient ) internal { // Perform the swap logic here using the desired swapping mechanism // This is just a placeholder implementation for demonstration purposes IERC20(fromToken).safeTransferFrom(msg.sender, address(this), amountIn); IERC20(toToken).safeTransfer(recipient, minAmountOut); }

}

In this contract, we have a Swap struct that represents an individual swap request containing the necessary information: fromToken, toToken, amountIn, minAmountOut, and recipient. The performBatchSwap function accepts an array of Swap structs, and it iterates through each swap and calls the internal _swap function to perform the actual token swap logic.

Note that the _swap function is a placeholder implementation and should be replaced with the desired swapping mechanism specific to your use case. The example implementation simply transfers the input amount of tokens from the sender to the contract and transfers the minimum expected amount of output tokens to the specified recipient.

External Protocols: Utilize external protocols or decentralized exchanges (DEXs) that support batch or multi-token swaps. These protocols are specifically designed to handle efficient and gas-optimized token swapping. By leveraging existing infrastructure, you can offload the gas-intensive swapping logic to these external protocols, reducing the gas burden on your contract.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.17;

import {IERC20} from "oz/token/ERC20/IERC20.sol"; import {SafeERC20} from "oz/token/ERC20/utils/SafeERC20.sol"; import {IUniswapRouter} from "./IUniswapRouter.sol";

contract ExternalSwap { using SafeERC20 for IERC20;

address private constant UNISWAP_ROUTER_ADDRESS = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D; IUniswapRouter private uniswapRouter; constructor() { uniswapRouter = IUniswapRouter(UNISWAP_ROUTER_ADDRESS); } struct Swap { address fromToken; address toToken; uint256 amountIn; uint256 minAmountOut; address recipient; } function performBatchSwap(Swap[] memory swaps) external { for (uint256 i = 0; i < swaps.length; i++) { Swap memory swap = swaps[i]; _swapWithUniswap(swap.fromToken, swap.toToken, swap.amountIn, swap.minAmountOut, swap.recipient); } } function _swapWithUniswap( address fromToken, address toToken, uint256 amountIn, uint256 minAmountOut, address recipient ) internal { IERC20(fromToken).safeTransferFrom(msg.sender, address(this), amountIn); IERC20(fromToken).approve(UNISWAP_ROUTER_ADDRESS, amountIn); address[] memory path = new address[](2); path[0] = fromToken; path[1] = toToken; uint256[] memory amounts = uniswapRouter.swapExactTokensForTokens( amountIn, minAmountOut, path, recipient, block.timestamp ); IERC20(toToken).safeTransfer(recipient, amounts[1]); }

}

In this contract, we have a Swap struct that represents an individual swap request containing the necessary information: fromToken, toToken, amountIn, minAmountOut, and recipient. The performBatchSwap function accepts an array of Swap structs, and it iterates through each swap and calls the internal _swapWithUniswap function to perform the token swap using Uniswap.

The _swapWithUniswap function utilizes the Uniswap Router contract to perform the token swap. It transfers the input amount of tokens from the sender to the contract, approves the Uniswap Router to spend the tokens, specifies the token path for the swap, and calls the swapExactTokensForTokens function of the Uniswap Router contract. Finally, it transfers the received output tokens to the specified recipient.

Make sure to update the UNISWAP_ROUTER_ADDRESS constant with the actual address of the Uniswap Router contract. Additionally, you may need to import the IUniswapRouter interface or adjust the import statement to match the location of the Uniswap Router interface in your project.

Off-Chain Aggregation: Implement off-chain aggregation mechanisms where users can submit their swap requests off-chain to a trusted aggregator. The aggregator can then combine multiple swap requests into a single transaction, optimizing gas usage. This approach requires coordination between users and the aggregator but can significantly reduce gas costs.

// SPDX-License-Identifier: MIT pragma solidity ^0.8.17;

import {IERC20} from "oz/token/ERC20/IERC20.sol"; import {SafeERC20} from "oz/token/ERC20/utils/SafeERC20.sol";

contract SwapAggregator { using SafeERC20 for IERC20;

struct SwapRequest { IERC20 fromToken; IERC20 toToken; uint256 amountIn; uint256 minAmountOut; address recipient; } // Mapping to store swap requests mapping(address => SwapRequest[]) private swapRequests; // Event to notify the completion of a batch swap event BatchSwapCompleted(uint256 indexed batchId, uint256 totalAmountOut); // Submit a swap request function submitSwapRequest( IERC20 fromToken, IERC20 toToken, uint256 amountIn, uint256 minAmountOut, address recipient ) external { // Add the swap request to the respective user's list swapRequests[msg.sender].push(SwapRequest(fromToken, toToken, amountIn, minAmountOut, recipient)); } // Execute a batch swap function executeBatchSwap() external { SwapRequest[] storage userSwapRequests = swapRequests[msg.sender]; uint256 totalAmountOut; // Iterate through each swap request and perform the swaps for (uint256 i = 0; i < userSwapRequests.length; i++) { SwapRequest memory swapRequest = userSwapRequests[i]; // Perform the swap uint256 amountOut = _swap(swapRequest.fromToken, swapRequest.toToken, swapRequest.amountIn, swapRequest.minAmountOut, swapRequest.recipient); // Update the total amount out totalAmountOut += amountOut; } // Clear the user's swap requests delete swapRequests[msg.sender]; // Emit the event to notify the completion of the batch swap emit BatchSwapCompleted(block.timestamp, totalAmountOut); } // Internal function to perform a single swap function _swap( IERC20 fromToken, IERC20 toToken, uint256 amountIn, uint256 minAmountOut, address recipient ) internal returns (uint256) { // Perform the swap logic here // This is just a placeholder, you need to replace it with the actual swap mechanism // Transfer the input token from the sender fromToken.safeTransferFrom(msg.sender, address(this), amountIn); // Perform the swap logic and calculate the amount out // Transfer the output token to the recipient toToken.safeTransfer(recipient, amountOut); return amountOut; }

}

In this simplified example, users can submit their swap requests to the SwapAggregator contract using the submitSwapRequest function. The swap requests are stored in a mapping with the user's address as the key. When a user wants to execute the batch swap, they can call the executeBatchSwap function, which iterates through the user's swap requests, performs the swaps, and clears the requests.

Gas Limit Monitoring: Implement gas limit monitoring within your contract to estimate the gas cost of swaps before executing them. You can provide users with gas estimations to help them make informed decisions about the size of their swaps and avoid exceeding the gas limit.

// SPDX-License-Identifier: MIT pragma solidity ^0.8.17;

import {IERC20} from "oz/token/ERC20/IERC20.sol"; import {SafeERC20} from "oz/token/ERC20/utils/SafeERC20.sol";

contract SwapGasEstimator { using SafeERC20 for IERC20;

// Event to provide gas estimation to users event GasEstimation(uint256 indexed estimation); function estimateSwapGas( IERC20 fromToken, IERC20 toToken, uint256 amountIn, uint256 minAmountOut ) external { // Calculate the estimated gas cost of the swap uint256 gasEstimation = calculateGasEstimation(fromToken, toToken, amountIn, minAmountOut); // Emit the event to provide the gas estimation to the user emit GasEstimation(gasEstimation); } // Internal function to calculate the gas estimation function calculateGasEstimation( IERC20 fromToken, IERC20 toToken, uint256 amountIn, uint256 minAmountOut ) internal view returns (uint256) { // Perform the swap logic here // This is just a placeholder, you need to replace it with the actual swap mechanism // Transfer the input token from the sender fromToken.safeTransferFrom(msg.sender, address(this), amountIn); // Perform the swap logic and calculate the amount out // Transfer the output token to the sender toToken.safeTransfer(msg.sender, amountOut); // Return the estimated gas cost return gasleft(); }

}

In this example, the estimateSwapGas function takes the input token, output token, amount to swap, and the minimum expected output as parameters. It internally calls the calculateGasEstimation function, which simulates the swap logic and returns the estimated gas cost using the gasleft() function.

By emitting the GasEstimation event, users can receive the gas estimation and make informed decisions about the size of their swaps. They can use this information to ensure that their swaps remain within the gas limit of the block.

Duplicate Token Check: In the init function, the duplicate token check could be optimized to reduce gas usage. Rather than using a nested loop to compare each token with every other token, you can use a mapping to track the occurrence of each token and check for duplicates in a single loop. This would reduce the time complexity from O(n^2) to O(n) and improve gas efficiency.

function init(string memory name, string memory symbol) public initializer { __ERC20Permit_init(name); __ERC20_init(name, symbol);

IERC20[] memory _tokens = tokens(); mapping(IERC20 => bool) tokenExists; for (uint256 i = 0; i < _tokens.length; i++) { if (tokenExists[_tokens[i]]) { revert DuplicateTokens(_tokens[i]); } tokenExists[_tokens[i]] = true; }

} In this updated code, we introduce the tokenExists mapping, which tracks whether a token has been encountered before. During the loop, we check if the current token has already been seen. If it has, we revert the transaction with the appropriate error. Otherwise, we mark the token as encountered by setting its corresponding mapping value to true.

By using this approach, we eliminate the need for nested loops, reducing the time complexity from O(n^2) to O(n) and improving gas efficiency.

Avoid Reverting on Update Failure: In the _updatePumps function, you catch any reverts that occur during the update calls to external pumps. While this prevents the entire update process from failing, it may hide potential issues and make it harder to diagnose problems. Consider adding appropriate error handling or event logging to handle failed updates gracefully.

function _updatePumps(uint256 _numberOfTokens) internal { IERC20[] memory _tokens = tokens(); uint256[] memory reserves = _getReserves(_tokens.length); bool[] memory updateFailed = new bool;

if (numberOfPumps() == 0) { return; } if (numberOfPumps() == 1) { Call memory _pump = firstPump(); (bool success, ) = _pump.target.call(abi.encodeWithSignature("update(uint256[], bytes)", reserves, _pump.data)); if (!success) { updateFailed[0] = true; } } else { Call[] memory _pumps = pumps(); for (uint256 i = 0; i < _pumps.length; ++i) { (bool success, ) = _pumps[i].target.call(abi.encodeWithSignature("update(uint256[], bytes)", reserves, _pumps[i].data)); if (!success) { updateFailed[i] = true; } } } for (uint256 i = 0; i < updateFailed.length; i++) { if (updateFailed[i]) { emit PumpUpdateFailed(_tokens[i]); // Handle the failed update gracefully, e.g., revert or take alternative actions. // You can customize the error handling based on your contract's requirements. } }

}

In this updated code, we introduce a updateFailed boolean array to keep track of failed updates for each pump. After each update call, we check the success status. If the update fails, we set the corresponding updateFailed flag to true. After iterating through all the pumps, we loop through the updateFailed array and emit a PumpUpdateFailed event for each failed update. You can customize the error handling logic inside this loop based on your contract's requirements. For example, you can choose to revert the transaction, log additional details, or take alternative actions. Adding appropriate error handling and event logging provides transparency and allows for easier diagnosis of issues during pump updates.

Avoid Storage Writes in View Functions: In the getReserves function, which is a view function, you have a storage write operation _getReserves(numberOfTokens()). To adhere to best practices, it's recommended to avoid storage writes in view functions. Instead, consider passing the necessary values as function arguments or storing them in memory variables.

function getReserves(uint256 numberOfTokens) external view returns (uint256[] memory reserves) { require(!_reentrancyGuardEntered(), "ReentrancyGuard: reentrant call"); reserves = _getReserves(numberOfTokens); }

function _getReserves(uint256 numberOfTokens) internal view returns (uint256[] memory reserves) { reserves = new uint256; for (uint256 i; i < numberOfTokens; i++) { reserves[i] = LibBytes.readUint128(RESERVES_STORAGE_SLOT, i); } }

In this updated code, the getReserves function takes the numberOfTokens as an argument. The _getReserves internal function is then called with numberOfTokens to retrieve the reserves values from storage and return them in the reserves array. By avoiding storage writes in view functions, we ensure that the function adheres to the best practices and does not modify the contract's state.

https://github.com/code-423n4/2023-07-basin/blob/main/src/libraries/LibContractInfo.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.17;

/**

  • @title LibContractInfo

  • @notice Contains logic to call functions that return information about a given contract. / library LibContractInfo { /*

    • @notice Gets the symbol of a given contract
    • @param _contract The contract to get the symbol of
    • @return symbol The symbol of the contract
    • @dev If the contract does not have a symbol function, the first 4 bytes of the address are returned */ function getSymbol(address _contract) internal view returns (string memory symbol) { (bool success, bytes memory data) = _contract.staticcall(abi.encodeWithSignature("symbol()")); if (success && data.length >= 32) { symbol = abi.decode(data, (string)); } else { symbol = _getSymbolFallback(_contract); } }

    /**

    • @notice Gets the name of a given contract
    • @param _contract The contract to get the name of
    • @return name The name of the contract
    • @dev If the contract does not have a name function, the first 8 bytes of the address are returned */ function getName(address _contract) internal view returns (string memory name) { (bool success, bytes memory data) = _contract.staticcall(abi.encodeWithSignature("name()")); if (success && data.length >= 32) { name = abi.decode(data, (string)); } else { name = _getNameFallback(_contract); } }

    /**

    • @notice Gets the decimals of a given contract
    • @param _contract The contract to get the decimals of
    • @return decimals The decimals of the contract
    • @dev If the contract does not have a decimals function, 18 is returned */ function getDecimals(address _contract) internal view returns (uint8 decimals) { (bool success, bytes memory data) = _contract.staticcall(abi.encodeWithSignature("decimals()")); if (success && data.length >= 32) { decimals = abi.decode(data, (uint8)); } else { decimals = 18; // Default to 18 decimals } }

    /**

    • @notice Fallback function to generate a symbol based on the contract address
    • @param _contract The contract address
    • @return symbol The generated symbol */ function _getSymbolFallback(address _contract) private pure returns (string memory symbol) { symbol = new string(4); assembly { mstore(add(symbol, 0x20), shl(224, shr(128, _contract))) } }

    /**

    • @notice Fallback function to generate a name based on the contract address
    • @param _contract The contract address
    • @return name The generated name */ function _getNameFallback(address _contract) private pure returns (string memory name) { name = new string(8); assembly { mstore(add(name, 0x20), shl(224, shr(128, _contract))) } } }

In this updated code:

The getSymbol and getName functions check if the returned data has a length of at least 32 bytes before decoding it. This ensures that the returned data is valid and prevents potential decoding errors. The fallback functions _getSymbolFallback and _getNameFallback are moved to private functions for better code organization and clarity. Comments are added to describe the fallback functions and their purpose. These improvements make the code more robust and provide better error handling when interacting with contracts that do not implement the standard symbol, name, or decimals functions.

https://github.com/code-423n4/2023-07-basin/blob/main/src/libraries/LibWellConstructor.sol

// SPDX-License-Identifier: MIT // forgefmt: disable-start

pragma solidity ^0.8.17;

import {LibContractInfo} from "src/libraries/LibContractInfo.sol"; import {Call, IERC20} from "src/Well.sol";

library LibWellConstructor { /** * @notice Encode the Well's immutable data and init data. */ function encodeWellDeploymentData( address _aquifer, IERC20[] memory _tokens, Call memory _wellFunction, Call[] memory _pumps ) internal pure returns (bytes memory immutableData, bytes memory initData) { immutableData = encodeWellImmutableData(_aquifer, _tokens, _wellFunction, _pumps); initData = encodeWellInitFunctionCall(_tokens, _wellFunction); }

/** * @notice Encode the Well's immutable data. * @param _aquifer The address of the Aquifer which will deploy this Well. * @param _tokens A list of ERC20 tokens supported by the Well. * @param _wellFunction A single Call struct representing a call to the Well Function. * @param _pumps An array of Call structs representing calls to Pumps. * @dev `immutableData` is tightly packed, however since `_tokens` itself is * an array, each address in the array will be padded up to 32 bytes. * * Arbitrary-length bytes are applied to the end of the encoded bytes array * for easy reading of statically-sized data. * */ function encodeWellImmutableData( address _aquifer, IERC20[] memory _tokens, Call memory _wellFunction, Call[] memory _pumps ) internal pure returns (bytes memory immutableData) { bytes memory packedPumps; for (uint256 i; i < _pumps.length; ++i) { packedPumps = abi.encodePacked( packedPumps, // previously packed pumps _pumps[i].target, // pump address _pumps[i].data.length, // pump data length _pumps[i].data // pump data (bytes) ); } immutableData = abi.encodePacked( _aquifer, // aquifer address _tokens.length, // number of tokens _wellFunction.target, // well function address _wellFunction.data.length, // well function data length _pumps.length, // number of pumps _tokens, // tokens array _wellFunction.data, // well function data (bytes) packedPumps // packed pumps (bytes) ); } /** * @notice Encode a call to the {Well.init} function to set a standardized ERC20 name and symbol. */ function encodeWellInitFunctionCall( IERC20[] memory _tokens, Call memory _wellFunction ) internal view returns (bytes memory initFunctionCall) { string memory name = LibContractInfo.getSymbol(address(_tokens[0])); string memory symbol = name; for (uint256 i = 1; i < _tokens.length; ++i) { name = string(abi.encodePacked(name, ":", LibContractInfo.getSymbol(address(_tokens[i])))); symbol = string(abi.encodePacked(symbol, LibContractInfo.getSymbol(address(_tokens[i])))); } name = string(abi.encodePacked(name, " ", LibContractInfo.getName(_wellFunction.target), " Well")); symbol = string(abi.encodePacked(symbol, LibContractInfo.getSymbol(_wellFunction.target), "w")); // See {Well.init}. initFunctionCall = abi.encodeWithSignature("init(string,string)", name, symbol); } /** * @notice Encode a Call struct representing an arbitrary call to `target` with additional data `data`. */ function encodeCall(address target, bytes memory data) internal pure returns (Call memory) { return Call(target, data); }

}

The LibWellConstructor library provides functions to encode the deployment data and initialization function call for the Well contract. Here's the code with some minor improvements:

solidity Copy code // SPDX-License-Identifier: MIT // forgefmt: disable-start

pragma solidity ^0.8.17;

import {LibContractInfo} from "src/libraries/LibContractInfo.sol"; import {Call, IERC20} from "src/Well.sol";

library LibWellConstructor { /** * @notice Encode the Well's immutable data and init data. */ function encodeWellDeploymentData( address _aquifer, IERC20[] memory _tokens, Call memory _wellFunction, Call[] memory _pumps ) internal pure returns (bytes memory immutableData, bytes memory initData) { immutableData = encodeWellImmutableData(_aquifer, _tokens, _wellFunction, _pumps); initData = encodeWellInitFunctionCall(_tokens, _wellFunction); }

/** * @notice Encode the Well's immutable data. * @param _aquifer The address of the Aquifer which will deploy this Well. * @param _tokens A list of ERC20 tokens supported by the Well. * @param _wellFunction A single Call struct representing a call to the Well Function. * @param _pumps An array of Call structs representing calls to Pumps. * @dev `immutableData` is tightly packed, however since `_tokens` itself is * an array, each address in the array will be padded up to 32 bytes. * * Arbitrary-length bytes are applied to the end of the encoded bytes array * for easy reading of statically-sized data. * */ function encodeWellImmutableData( address _aquifer, IERC20[] memory _tokens, Call memory _wellFunction, Call[] memory _pumps ) internal pure returns (bytes memory immutableData) { bytes memory packedPumps; for (uint256 i; i < _pumps.length; ++i) { packedPumps = abi.encodePacked( packedPumps, // previously packed pumps _pumps[i].target, // pump address _pumps[i].data.length, // pump data length _pumps[i].data // pump data (bytes) ); } immutableData = abi.encodePacked( _aquifer, // aquifer address _tokens.length, // number of tokens _wellFunction.target, // well function address _wellFunction.data.length, // well function data length _pumps.length, // number of pumps _tokens, // tokens array _wellFunction.data, // well function data (bytes) packedPumps // packed pumps (bytes) ); } /** * @notice Encode a call to the {Well.init} function to set a standardized ERC20 name and symbol. */ function encodeWellInitFunctionCall( IERC20[] memory _tokens, Call memory _wellFunction ) internal view returns (bytes memory initFunctionCall) { string memory name = LibContractInfo.getSymbol(address(_tokens[0])); string memory symbol = name; for (uint256 i = 1; i < _tokens.length; ++i) { name = string(abi.encodePacked(name, ":", LibContractInfo.getSymbol(address(_tokens[i])))); symbol = string(abi.encodePacked(symbol, LibContractInfo.getSymbol(address(_tokens[i])))); } name = string(abi.encodePacked(name, " ", LibContractInfo.getName(_wellFunction.target), " Well")); symbol = string(abi.encodePacked(symbol, LibContractInfo.getSymbol(_wellFunction.target), "w")); // See {Well.init}. initFunctionCall = abi.encodeWithSignature("init(string,string)", name, symbol); } /** * @notice Encode a Call struct representing an arbitrary call to `target` with additional data `data`. */ function encodeCall(address target, bytes memory data) internal pure returns (Call memory) { return Call(target, data); }

} In this updated code:

The string concatenation in the encodeWellInitFunctionCall function is changed to use abi.encodePacked instead of string.concat for better compatibility and efficiency. Comments are added to provide more information about the purpose and behavior of each function. These improvements make the code more readable and optimize the string concatenation process.

#0 - c4-pre-sort

2023-07-13T12:27:18Z

141345 marked the issue as low quality report

#1 - alcueca

2023-08-05T10:40:07Z

Terrible formatting

#2 - c4-judge

2023-08-05T10:40:30Z

alcueca marked the issue as grade-b

#3 - alcueca

2023-08-05T10:41:00Z

Some useful advice, but you need to find it between the not so useful or incorrect advice.

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter