Secure Multi-Token Staking Vault Solidity, Web3

👤 Sharing: AI
```solidity
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

/**
 * @title Secure Multi-Token Staking Vault
 * @dev Allows users to stake multiple ERC20 tokens and earn rewards.
 *  It also demonstrates reentrancy protection and ownership.
 */
contract MultiTokenStakingVault is Ownable, ReentrancyGuard {
    // Struct to hold staking information for each user
    struct StakeInfo {
        uint256 amount;   // Amount of token staked
        uint256 startTime; // Time when staking started
    }

    // Mapping from token address to staking reward per block
    mapping(address => uint256) public rewardPerBlock;

    // Mapping from token address to total tokens staked
    mapping(address => uint256) public totalStaked;

    // Mapping from user address to token address to StakeInfo
    mapping(address => mapping(address => StakeInfo)) public stakes;

    // Mapping from user address to token address to earned rewards
    mapping(address => mapping(address => uint256)) public earned;

    // Mapping from token address to whether the token is enabled
    mapping(address => bool) public isTokenEnabled;

    // Event emitted when a user stakes tokens
    event Staked(address indexed user, address indexed token, uint256 amount);

    // Event emitted when a user withdraws tokens
    event Withdrawn(address indexed user, address indexed token, uint256 amount);

    // Event emitted when rewards are claimed
    event RewardClaimed(address indexed user, address indexed token, uint256 amount);

    // Event emitted when reward rate is updated
    event RewardRateUpdated(address indexed token, uint256 newRate);

    /**
     * @dev Sets the reward rate for a specific token.  Only the owner can call this.
     * @param _token The address of the ERC20 token.
     * @param _rewardPerBlock The reward amount per block.
     */
    function setRewardRate(address _token, uint256 _rewardPerBlock) public onlyOwner {
        require(isTokenEnabled[_token], "Token is not enabled.");
        rewardPerBlock[_token] = _rewardPerBlock;
        emit RewardRateUpdated(_token, _rewardPerBlock);
    }

    /**
     * @dev Enables staking for a specific token. Only the owner can call this.
     * @param _token The address of the ERC20 token.
     */
    function enableToken(address _token) public onlyOwner {
        isTokenEnabled[_token] = true;
    }

    /**
     * @dev Disables staking for a specific token. Only the owner can call this.
     * @param _token The address of the ERC20 token.
     */
    function disableToken(address _token) public onlyOwner {
        isTokenEnabled[_token] = false;
    }


    /**
     * @dev Stakes a specified amount of tokens.
     * @param _token The address of the ERC20 token being staked.
     * @param _amount The amount of tokens to stake.
     */
    function stake(address _token, uint256 _amount) public nonReentrant {
        require(isTokenEnabled[_token], "Token is not enabled for staking.");
        require(_amount > 0, "Amount must be greater than zero.");

        IERC20 token = IERC20(_token);

        // Transfer tokens from the user to the contract
        token.transferFrom(msg.sender, address(this), _amount);

        // Update staking information
        if (stakes[msg.sender][_token].amount == 0) {
            stakes[msg.sender][_token] = StakeInfo(_amount, block.timestamp); // Store the stake
        } else {
            // Existing stake. Need to claim rewards first!
            claimReward(_token);
            stakes[msg.sender][_token].amount += _amount; // Increment existing stake
        }

        totalStaked[_token] += _amount;
        emit Staked(msg.sender, _token, _amount);
    }

    /**
     * @dev Withdraws a specified amount of staked tokens.
     * @param _token The address of the ERC20 token being withdrawn.
     * @param _amount The amount of tokens to withdraw.
     */
    function withdraw(address _token, uint256 _amount) public nonReentrant {
        require(_amount > 0, "Amount must be greater than zero.");
        require(stakes[msg.sender][_token].amount >= _amount, "Insufficient staked balance.");

        IERC20 token = IERC20(_token);

        // Calculate and claim rewards before withdrawing
        claimReward(_token);

        // Update staking information
        stakes[msg.sender][_token].amount -= _amount;
        totalStaked[_token] -= _amount;

        // Transfer tokens from the contract to the user
        token.transfer(msg.sender, _amount);

        emit Withdrawn(msg.sender, _token, _amount);
    }

    /**
     * @dev Calculates the reward earned by a user for a specific token.
     * @param _token The address of the ERC20 token.
     * @param _user The address of the user.
     * @return The amount of reward earned.
     */
    function calculateReward(address _token, address _user) public view returns (uint256) {
      if(stakes[_user][_token].amount == 0){
          return 0;
      }
      uint256 timeElapsed = block.timestamp - stakes[_user][_token].startTime;
      return (stakes[_user][_token].amount * rewardPerBlock[_token] * timeElapsed) / 10**18; // Scale down for precision
    }

    /**
     * @dev Claims the reward earned by a user for a specific token.
     * @param _token The address of the ERC20 token.
     */
    function claimReward(address _token) public nonReentrant {
        uint256 reward = calculateReward(_token, msg.sender);
        if (reward > 0) {
            earned[msg.sender][_token] += reward;
            stakes[msg.sender][_token].startTime = block.timestamp;  // Reset start time so we dont calculate it again.

            IERC20 token = IERC20(_token);
            token.transfer(msg.sender, reward);  // Assuming rewards are paid in the same staking token
            emit RewardClaimed(msg.sender, _token, reward);
        }
    }

     /**
     * @dev Allows anyone to recover tokens accidentally sent to this contract.
     * @param _token The address of the ERC20 token.
     * @param _amount The amount to recover.
     */
    function recoverERC20(address _token, uint256 _amount) public onlyOwner {
        IERC20 token = IERC20(_token);
        require(token.balanceOf(address(this)) >= _amount, "Insufficient balance in contract.");
        token.transfer(owner(), _amount);
    }


    /**
     * @dev Fallback function to prevent accidental sending of Ether to the contract.
     */
    receive() external payable {
        require(msg.data.length == 0, "Do not send Ether directly to this contract.");
    }
}
```

**Explanation:**

1.  **`pragma solidity ^0.8.0;`**:  Specifies the Solidity compiler version to be used (0.8.0 or higher).

2.  **`import ...;`**: Imports necessary contracts from the OpenZeppelin library:
    *   `IERC20`: Interface for ERC20 tokens (for handling token transfers).
    *   `Ownable`: Provides ownership functionality (only the owner can call certain functions).
    *   `ReentrancyGuard`:  Protects against reentrancy attacks (prevents malicious contracts from repeatedly calling functions during execution).

3.  **`contract MultiTokenStakingVault is Ownable, ReentrancyGuard { ... }`**: Defines the contract `MultiTokenStakingVault`.  It inherits from `Ownable` and `ReentrancyGuard`.

4.  **`StakeInfo` struct**: Defines a structure to hold information about a user's stake:
    *   `amount`: The amount of tokens staked.
    *   `startTime`: The timestamp when the staking started.

5.  **State Variables**:
    *   `rewardPerBlock (mapping)`:  Stores the reward rate (amount of reward tokens given per block) for each staked token type. The address of the ERC20 token is the key.
    *   `totalStaked (mapping)`: Tracks the total amount of each token that has been staked in the contract.  The ERC20 token address is the key.
    *   `stakes (mapping)`:  A nested mapping that stores the `StakeInfo` for each user and token combination.  `stakes[user address][token address]` returns a `StakeInfo` struct.
    *   `earned (mapping)`: Keeps track of the rewards earned but not yet claimed by each user for each token.  `earned[user address][token address]` stores the amount.
    *   `isTokenEnabled (mapping)`: Tracks whether a specific token is enabled for staking. Only enabled tokens can be staked.

6.  **Events**: Define events emitted by the contract:
    *   `Staked`: Emitted when a user stakes tokens.
    *   `Withdrawn`: Emitted when a user withdraws tokens.
    *   `RewardClaimed`: Emitted when a user claims rewards.
    *   `RewardRateUpdated`: Emitted when the reward rate is updated.

7.  **`setRewardRate(address _token, uint256 _rewardPerBlock)`**:  Allows the owner to set the reward rate for a given token. It requires the token to be enabled for staking.
    *   `require(isTokenEnabled[_token], "Token is not enabled.");`: Checks if the token is enabled for staking.
    *   `rewardPerBlock[_token] = _rewardPerBlock;`: Sets the `rewardPerBlock` for the specified token.

8.  **`enableToken(address _token)`**: Allows the owner to enable staking for a particular ERC20 token.
    *   `isTokenEnabled[_token] = true;`

9.  **`disableToken(address _token)`**: Allows the owner to disable staking for a particular ERC20 token.
    *   `isTokenEnabled[_token] = false;`

10. **`stake(address _token, uint256 _amount)`**: Allows a user to stake a specified amount of tokens.
    *   `require(isTokenEnabled[_token], "Token is not enabled for staking.");`: Checks if the token is enabled.
    *   `require(_amount > 0, "Amount must be greater than zero.");`:  Ensures the stake amount is greater than zero.
    *   `IERC20 token = IERC20(_token);`: Creates an `IERC20` interface to interact with the ERC20 token.
    *   `token.transferFrom(msg.sender, address(this), _amount);`: Transfers the specified amount of tokens from the user to the contract.  The user needs to have approved this contract to spend tokens on their behalf using `approve()` in the ERC20 contract.
    *   `if (stakes[msg.sender][_token].amount == 0) { ... } else { ... }`: Checks if the user is staking the token for the first time.
        *   If first time, it stores the `amount` and `block.timestamp` (time of staking) in the `stakes` mapping.
        *   If not the first time, it claims the previous reward *before* incrementing the staking amount, preventing exploits.
    *   `totalStaked[_token] += _amount;`: Updates the total staked amount for the token.
    *   `emit Staked(msg.sender, _token, _amount);`: Emits a `Staked` event.

11. **`withdraw(address _token, uint256 _amount)`**:  Allows a user to withdraw a specified amount of their staked tokens.
    *   `require(_amount > 0, "Amount must be greater than zero.");`: Ensures the withdrawal amount is greater than zero.
    *   `require(stakes[msg.sender][_token].amount >= _amount, "Insufficient staked balance.");`: Checks if the user has sufficient staked tokens to withdraw.
    *   `claimReward(_token);`: Claims any outstanding rewards *before* the withdrawal.
    *   `stakes[msg.sender][_token].amount -= _amount;`: Updates the staked amount in the `stakes` mapping.
    *   `totalStaked[_token] -= _amount;`: Updates the total staked amount.
    *   `token.transfer(msg.sender, _amount);`: Transfers the tokens from the contract to the user.
    *   `emit Withdrawn(msg.sender, _token, _amount);`: Emits a `Withdrawn` event.

12. **`calculateReward(address _token, address _user)`**: Calculates the reward earned by a user for staking a specific token.
    *   `if(stakes[_user][_token].amount == 0){ return 0; }`: If the user has not staked, return zero.
    *   Calculates the time elapsed since the user started staking.
    *   Calculates the reward based on the formula: `(stakes[_user][_token].amount * rewardPerBlock[_token] * timeElapsed) / 10**18`. The `/ 10**18` is for scaling down the result to maintain precision.  This assumes `rewardPerBlock` represents a value with 18 decimal places of precision.

13. **`claimReward(address _token)`**: Allows a user to claim their accumulated rewards for a specific token.
    *   `uint256 reward = calculateReward(_token, msg.sender);`: Calculates the reward.
    *   `if (reward > 0) { ... }`: Checks if the reward is greater than zero.
        *   `earned[msg.sender][_token] += reward;`: Updates the `earned` mapping.
        *   `stakes[msg.sender][_token].startTime = block.timestamp;`: Resets the `startTime` to the current block timestamp, so rewards are calculated from this point onwards when the user claims again.
        *   `IERC20 token = IERC20(_token);`: Gets the IERC20 token contract.
        *   `token.transfer(msg.sender, reward);`: Transfers the reward tokens from the contract to the user.  Crucially, it's transferring tokens *of the same type* that were staked.
        *   `emit RewardClaimed(msg.sender, _token, reward);`: Emits a `RewardClaimed` event.

14. **`recoverERC20(address _token, uint256 _amount)`**: Allows the owner to recover accidentally sent ERC20 tokens.
    *   `require(token.balanceOf(address(this)) >= _amount, "Insufficient balance in contract.");`: Checks if the contract has enough of the specified token to recover.
    *   `token.transfer(owner(), _amount);`: Transfers the tokens to the owner.

15. **`receive() external payable { ... }`**: A fallback function that prevents Ether from being sent directly to the contract (by rejecting any transactions that send Ether).

**Key Security Considerations:**

*   **Reentrancy Guard:**  The `nonReentrant` modifier prevents reentrancy attacks, where a malicious contract calls back into the staking contract during the execution of a function (e.g., `withdraw`), potentially manipulating balances or state.
*   **Ownership:** The `Ownable` contract restricts access to sensitive functions like `setRewardRate`, `enableToken` and `disableToken` to the contract owner.
*   **Input Validation:**  The contract validates inputs (e.g., `_amount > 0`, `stakes[msg.sender][_token].amount >= _amount`) to prevent errors or malicious behavior.
*   **Safe Math (Implicit):** Solidity 0.8.0 and later versions have built-in overflow/underflow protection, so explicit safe math libraries (like `SafeMath`) are no longer necessary.
*   **Token Transfers:**  The contract uses `transferFrom` and `transfer` to handle token transfers.  It's critical to ensure the user has approved the contract to spend tokens on their behalf using the ERC20 `approve()` function *before* calling `stake()`.  The contract also checks the balances before initiating token transfers.
*   **Precision:** The scaling factor `10**18` in `calculateReward` assumes the ERC20 tokens have 18 decimal places. Adjust accordingly if your tokens have a different number of decimal places.  It's generally a good practice to use the smallest possible unit of the token for calculations (e.g., "wei" for Ether-like tokens).

**Example Usage (Web3.js):**

This example assumes you have a deployed contract and are using web3.js in a Javascript environment (e.g., a DApp).

```javascript
// Assumes you have web3 injected by MetaMask or other provider

const contractAddress = "0x..."; // Replace with your contract address
const stakingVaultAbi = [...] ; // Replace with your contract ABI

const stakingVaultContract = new web3.eth.Contract(stakingVaultAbi, contractAddress);

const tokenAddress = "0x..."; // Address of the ERC20 token to stake
const stakeAmount = web3.utils.toWei("10", "ether"); // Stake 10 tokens (adjust as needed)

async function stakeTokens() {
  try {
    // 1. Approve the contract to spend tokens on behalf of the user:
    const tokenContract = new web3.eth.Contract(erc20Abi, tokenAddress); // You'll need the ERC20 ABI
    const approveTx = await tokenContract.methods.approve(contractAddress, stakeAmount).send({ from: web3.eth.defaultAccount });
    console.log("Approval Transaction:", approveTx);

    // 2. Stake the tokens
    const stakeTx = await stakingVaultContract.methods.stake(tokenAddress, stakeAmount).send({ from: web3.eth.defaultAccount });
    console.log("Stake Transaction:", stakeTx);
  } catch (error) {
    console.error("Error staking tokens:", error);
  }
}

async function withdrawTokens(amountToWithdraw) {
    try {
        const withdrawAmount = web3.utils.toWei(amountToWithdraw, "ether");
        const withdrawTx = await stakingVaultContract.methods.withdraw(tokenAddress, withdrawAmount).send({from: web3.eth.defaultAccount});
        console.log("Withdrawal Transaction: ", withdrawTx);
    } catch (error) {
        console.error("Error withdrawing tokens: ", error);
    }
}

async function claimRewards() {
    try {
        const claimTx = await stakingVaultContract.methods.claimReward(tokenAddress).send({from: web3.eth.defaultAccount});
        console.log("Claim Reward Transaction: ", claimTx);
    } catch (error) {
        console.error("Error claiming rewards: ", error);
    }
}

async function checkReward() {
    try {
        const reward = await stakingVaultContract.methods.calculateReward(tokenAddress, web3.eth.defaultAccount).call();
        const readableReward = web3.utils.fromWei(reward, "ether");
        console.log("Reward Available: ", readableReward);
    } catch (error) {
        console.error("Error checking reward: ", error);
    }
}

// Example calls:
// stakeTokens();
// withdrawTokens("5"); // Withdraw 5 tokens
// claimRewards();
// checkReward();
```

**Important Notes for Web3.js integration:**

*   **ABI:** You'll need the ABI (Application Binary Interface) of both the staking vault contract and the ERC20 token contract.  The ABI describes the functions and events of the contract and allows web3.js to interact with it.  You get this from compiling your Solidity code with the Solidity compiler.
*   **`web3.eth.defaultAccount`:**  This represents the user's currently selected account in MetaMask or another web3 provider. Make sure the user has unlocked their wallet and selected an account.
*   **`web3.utils.toWei`:**  This function converts a human-readable amount (e.g., "10") to the smallest unit of the token (e.g., "wei" for tokens with 18 decimal places).
*   **`from: web3.eth.defaultAccount`:**  Specifies the account that is sending the transaction.
*   **Error Handling:**  The `try...catch` blocks are essential for handling errors that may occur during transaction execution.
*   **Gas:**  Transactions require gas to execute.  Web3.js automatically estimates gas, but you may need to adjust it manually if the estimate is too low or if you want to optimize gas usage.
*   **MetaMask:** MetaMask or a similar browser extension is required to sign and send transactions to the Ethereum network.
*   **ERC20 Approval:** *Crucially*, before calling `stake()`, the user *must* first call the `approve()` function on the ERC20 token contract to allow the staking contract to spend tokens on their behalf.

This comprehensive example should get you started with building a secure multi-token staking vault. Remember to thoroughly test your code before deploying it to a live environment! Also be wary of potential vulnerabilities and carefully review all aspects of your smart contract for security.
👁️ Viewed: 8

Comments