Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 250 additions & 0 deletions solidity/src/vaults/ValenceXCV.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.28;

import {ERC4626Upgradeable} from "@openzeppelin-contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol";
import {BaseAccount} from "../accounts/BaseAccount.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {OwnableUpgradeable} from "@openzeppelin-contracts-upgradeable/access/OwnableUpgradeable.sol";
import {Initializable} from "@openzeppelin-contracts-upgradeable/proxy/utils/Initializable.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC7540Operator} from "../vaults/interfaces/IERC7540Operator.sol";
import {IValenceVaultStrategist} from "../vaults/interfaces/IValenceVaultStrategist.sol";
import {UUPSUpgradeable} from "@openzeppelin-contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

contract ValenceXCV is
Initializable,
ERC4626Upgradeable,
IERC7540Operator,
OwnableUpgradeable,
IValenceVaultStrategist,
UUPSUpgradeable
{
using Math for uint256;

error InvalidSharePrice();
error OnlyStrategistAllowed();
error NotControllerOrOperator();
error DepositAccountNotSet();
error StrategistNotSet();
error ZeroDepositAmount();
error StaleSharePrice();
error InvalidSharePriceMaxAge();
error SharePriceChangeDeltaTooLarge();

/// @dev The precision factor for one share, equivalent to 10^decimals().
uint256 internal ONE_SHARE;

/// The current price of one share in terms of the underlying asset.
uint256 public sharePrice;
/// The timestamp of the last share price update.
uint256 public lastUpdateTimestamp;
/// The maximum age of the share price in seconds before it is considered stale.
uint256 public sharePriceMaxAge;
/// Max allowed price change in a single share price update, in bips (1/100th of 1%)
uint256 public maxPriceChange;

/// The address of the authorized strategist who can update the share price.
address public strategist;

/// The Valence account contract that holds the deposited funds.
address public depositAccount;

/// Mapping of controllers to their approved operators.
mapping(address controller => mapping(address operator => bool)) public operators;

/// @dev Restricts function access to strategist
modifier onlyStrategist() {
if (msg.sender != strategist) {
revert OnlyStrategistAllowed();
}
_;
}

/// @dev Restricts function access to the controller or any operators
/// approved by the controller
/// @param controller address of the target position owner
modifier onlyControllerOrOperator(address controller) {
if (controller != msg.sender && !operators[controller][msg.sender]) {
revert NotControllerOrOperator();
}
_;
}

// TODO: think whether this should instead be a hard pause (in state) that
// would require owner intervention
/// @dev Restricts function to cases where the share price is up to date
modifier onlyWhenSharePriceNotStale() {
if (block.timestamp - lastUpdateTimestamp > sharePriceMaxAge) {
revert StaleSharePrice();
}
_;
}

/// @dev Constructor that disables initializers
/// @notice Required for UUPS proxy pattern
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}

/// Initializes the vault.
/// @param _owner The owner of the vault.
/// @param strategistAddress The address of the strategist.
/// @param underlying The address of the underlying asset token.
/// @param depositAccountAddress The address of the deposit account.
/// @param vaultTokenName The name of the vault token.
/// @param vaultTokenSymbol The symbol of the vault token.
/// @param startSharePrice The initial share price.
/// @param maxSharePriceAge The maximum age of the share price in seconds.
function initialize(
address _owner,
address strategistAddress,
address underlying,
address depositAccountAddress,
string memory vaultTokenName,
string memory vaultTokenSymbol,
uint256 startSharePrice,
uint256 maxSharePriceAge,
uint256 maxPriceChangeBps
) external initializer {
// initialize the vault share token
__ERC20_init(vaultTokenName, vaultTokenSymbol);
// initialize the underlying (deposit) token
__ERC4626_init(IERC20(underlying));
// set up ownership
__Ownable_init(_owner);
// proxy
__UUPSUpgradeable_init();

if (startSharePrice == 0) revert InvalidSharePrice();
sharePrice = startSharePrice;
lastUpdateTimestamp = block.timestamp;
maxPriceChange = maxPriceChangeBps;

if (maxSharePriceAge == 0) revert InvalidSharePriceMaxAge();
sharePriceMaxAge = maxSharePriceAge;

if (depositAccountAddress == address(0)) revert DepositAccountNotSet();
depositAccount = depositAccountAddress;

if (strategistAddress == address(0)) revert StrategistNotSet();
strategist = strategistAddress;

// set the share precision based on the underlying token decimals
ONE_SHARE = 10 ** decimals();
}

/// @dev Function that authorizes contract upgrades - required by UUPSUpgradeable
/// @param newImplementation address of the new implementation
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {
// Upgrade logic comes here
// No additional logic required beyond owner check in modifier
}

// ========================================================================
// ========================= ERC-7540 OPERATOR ============================
// ========================================================================

/// Checks whether a given address is an approved operator for a controller
/// @param controller The address of the controller
/// @param operator The address of the operator to check
/// @return A boolean indicating whether the operator is approved
function isOperator(address controller, address operator) external view returns (bool) {
return operators[controller][operator];
}

/// Approves or revokes an operator for the caller
/// @param operator The address of the operator
/// @param approved A boolean indicating whether to approve or revoke the operator
/// @return A boolean indicating the success of the operation (always true)
function setOperator(address operator, bool approved) external returns (bool) {
// set the operator status
operators[msg.sender][operator] = approved;
emit OperatorSet(msg.sender, operator, approved);
return true;
}

// ========================================================================
// ============================ DEPOSIT LANE ==============================
// ========================================================================

/// Sets the share price. Can only be called by the strategist
/// @param newSharePrice The new share price
function setSharePrice(uint256 newSharePrice) external onlyStrategist {
// setting share price to 0 would allow for infinite mint
if (newSharePrice == 0) revert InvalidSharePrice();

uint256 oldSharePrice = sharePrice;
// get the absolute difference between the new and old share prices
uint256 delta = newSharePrice > oldSharePrice ? newSharePrice - oldSharePrice : oldSharePrice - newSharePrice;

// verify that the delta is within our tolerance
// maxPriceChange is expressed in bips, so we multiply the delta by 10000
if ((delta * 10000) / oldSharePrice > maxPriceChange) {
revert SharePriceChangeDeltaTooLarge();
}

sharePrice = newSharePrice;
lastUpdateTimestamp = block.timestamp;

emit SharePriceUpdated(newSharePrice, lastUpdateTimestamp);
}

/// Converts a given amount of assets to shares
/// @param assets The amount of assets to convert
/// @param rounding The rounding direction
/// @return The corresponding amount of shares
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view override returns (uint256) {
return assets.mulDiv(ONE_SHARE, sharePrice, rounding);
}

/// Deposits assets into the vault and mints shares for the receiver.
/// This is the ERC-4626 compliant deposit function.
/// @dev calls into deposit(assets, receiver, controller) with controller = msg.sender
/// @param assets The amount of assets to deposit
/// @param receiver The address that will receive the shares
/// @return shares The amount of shares minted
function deposit(uint256 assets, address receiver) public override returns (uint256 shares) {
return deposit(assets, receiver, msg.sender);
}

/// Deposits assets into the vault and mints shares for the receiver.
/// This is the ERC-7540 compliant deposit function.
/// @param assets The amount of assets to deposit
/// @param receiver The address that will receive the shares
/// @param controller The address of the controller on whose behalf the deposit is made
/// @return shares The amount of shares minted
function deposit(uint256 assets, address receiver, address controller)
public
onlyWhenSharePriceNotStale
onlyControllerOrOperator(controller)
returns (uint256 shares)
{
// zero deposits are not allowed
if (assets == 0) {
revert ZeroDepositAmount();
}

// calculate the shares to be minted based on provided assets
// and the current share price
shares = convertToShares(assets);

_deposit(msg.sender, receiver, assets, shares);
}

/// Internal deposit logic. Transfers assets to the deposit account and mints shares.
/// Only modification to the default ERC-4626 is that deposited assets are escrowed
/// to the deposit account (external contract).
/// @param caller The address that initiated the deposit
/// @param receiver The address that will receive the shares
/// @param assets The amount of assets to deposit
/// @param shares The amount of shares to mint
function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override {
// escrow the deposited assets to the deposit account (external contract)
SafeERC20.safeTransferFrom(IERC20(asset()), receiver, depositAccount, assets);
_mint(receiver, shares);

emit Deposit(caller, receiver, assets, shares);
}
}
24 changes: 24 additions & 0 deletions solidity/src/vaults/interfaces/IERC7540Operator.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.28;

// interface for operator functionality inspired by ERC-6909, as described in ERC-7540:
// https://eips.ethereum.org/EIPS/eip-7540#operators

interface IERC7540Operator {
/**
* @dev Emitted when `controller` grants or revokes operator status for a `spender`.
*/
event OperatorSet(address indexed controller, address indexed operator, bool approved);

/**
* @dev Returns true if `operator` is set as an operator for `controller`.
*/
function isOperator(address controller, address operator) external view returns (bool);

/**
* @dev Grants or revokes `operator` rights to issue ERC-7540 requests on behalf of the caller (controller).
*
* Returns true if the operation was successful.
*/
function setOperator(address operator, bool approved) external returns (bool);
}
13 changes: 13 additions & 0 deletions solidity/src/vaults/interfaces/IValenceVaultStrategist.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.28;

interface IValenceVaultStrategist {
/// @dev Emitted on successful share price update by the strategist.
/// @param sharePrice newly posted share price
/// @param updateTimestamp block.time of the update
event SharePriceUpdated(uint256 indexed sharePrice, uint256 indexed updateTimestamp);

/// Sets the share price.
/// @param newSharePrice The new share price.
function setSharePrice(uint256 newSharePrice) external;
}
Loading
Loading