diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..963354f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "printWidth": 120 +} diff --git a/foundry.toml b/foundry.toml index e30dc2f..36d0368 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,7 +3,7 @@ src = 'src' out = 'out' libs = ['lib'] test = 'test' -solc = "0.8.26" +auto_detect_solc = true evm_version = "cancun" optimizer = true optimizer_runs = 1000000 diff --git a/src/interfaces/IAuthorizableV2.sol b/src/interfaces/IAuthorizableV2.sol new file mode 100644 index 0000000..a708773 --- /dev/null +++ b/src/interfaces/IAuthorizableV2.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IAuthorizeV2} from "./IAuthorizeV2.sol"; + +/** + * @title IAuthorizable + * @notice Interface for a contract that is using Authorize logic. + */ +interface IAuthorizableV2 { + /** + * @notice Emitted when the authorizer contract is changed. + * @param newAuthorizer The address of the new authorizer contract. + */ + event AuthorizerChanged(IAuthorizeV2 indexed newAuthorizer); + + /** + * @dev Sets the authorizer contract. + * @param newAuthorizer The address of the authorizer contract. + */ + function setAuthorizer(IAuthorizeV2 newAuthorizer) external; +} diff --git a/src/interfaces/IAuthorizeV2.sol b/src/interfaces/IAuthorizeV2.sol new file mode 100644 index 0000000..8ed7f36 --- /dev/null +++ b/src/interfaces/IAuthorizeV2.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/** + * @title IAuthorize + * @notice Interface for an authorization contract that validates if certain actions are allowed. + */ +interface IAuthorizeV2 { + error Unauthorized(bytes authData); + + function authorize(bytes calldata authData) external view returns (bool); +} diff --git a/src/interfaces/ITradingVault.sol b/src/interfaces/ITradingVault.sol new file mode 100644 index 0000000..0d48dee --- /dev/null +++ b/src/interfaces/ITradingVault.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// interface IFundingSource { +// allowance(address token, address owner, address spender) external view returns (uint256); +// setAllowanceFor(address token, address spender, uint256 amount) external; +// pullFrom(address otherFundingSource, address token, address owner, uint256 amount) external; +// transferTo(address token, address to, uint256 amount) external; +// } + +interface ITradingStructs { + /// @notice Defines where account's funds are taken from and sent to. + enum FundingLocation { + // TODO: maybe rename to "External", that will need to implement a specific interface, that allows pulling funds + // This way: + // 1. both TradingVault and LiquidityMiningVault are the same funding source. + // 2. other External funding sources can be easily added. + // This also requires passing an additional "address" parameter, to know which source to pull funds from. + + // What if the "FundingSource" is an address? This way, if the "trader" == "fundingSource", the funds are taken from the trader's balance. + // Otherwise, the contract at the "fundingSource" address is called to pull funds from the trader's balance. + + // Continuing this idea, "pulling funds" is basically calling "asset.TransferFrom(from, to, amount)", + // and means there is no need to differentiate between `Account` and `External` funding sources. + // NOTE: ETH does not have "transferFrom" method, so it must be handled separately or used Wrapped. + // Having said that, all funding sources must support an "approval" feature, including TradingVault and LiquidityMiningVault. + // Also, in case trader and broker settle between different EFSs, each of them must be aware when funds are transferred to them. + // This is impossible to achieve with a simple ERC20 transfer, therefore, EFSs must implement a specific interface that + // not only transfer funds, but if a destination is another EFS, it must notify the destination about the transfer. + // This can be done by calling a destination EFS and asking it to pull funds. + + // even if the funding source of both actors is the same, it will pull funds from itself successfully. + + // The problem is that in such case users in each EFS must approve funds to all possible other EFSs, which is not convenient. + // A move convenient method would be for the TradingVault to orchestrate this process like an intermediary. + // This way, users will need to approve funds in their respective EFSs only to the TradingVault, and the TradingVault will + // handle the exchange. + + // This also solves the transfer from an Address to an EFS, as the Address will approve the TradingVault to pull funds from it, + // and the TradingVault will handle the transfer to the EFS. + + /// @dev funds are taken from the TradingVault account's balance + TradingVault, + /// @dev funds are pulled from the account's token balance + Address + } + + struct Session { + bool isActive; + uint256 nonce; + } + + struct Allocation { + address asset; + uint256 amount; + } + + struct Funding { + Allocation allocation; + FundingLocation source; + } + + struct Intent { + address trader; + Allocation[] allocations; + uint256 nonce; + } + + struct Outcome { + address trader; + // NOTE: can be extended to a funding source per position + FundingLocation traderFundingDestination; + FundingLocation brokerFundingDestination; + Funding[] traderGives; + Funding[] brokerGives; + uint256 nonce; + } +} + +/** + * @title ITradingTerminal + * @author nksazonov + * @notice An ownerful implementation of a trading terminal that allows to start and end trading sessions, settle traders and liquidate. + */ +interface ITradingVault { + event Deposited(address indexed user, address indexed token, uint256 amount); + event Withdrawn(address indexed user, address indexed token, uint256 amount); + + event Settled(address indexed trader, uint256 nonce); + event Liquidated(address indexed trader, uint256 nonce); + + error InvalidAddress(); + error IncorrectValue(); + error InvalidAmount(); + error InsufficientBalance(address token, uint256 required, uint256 available); + error NativeTransferFailed(); + + error NonceMismatch(uint256 expected, uint256 actual); + error InvalidFundingLocation(); + error InvalidAssetOutcome(); + + function balanceOf(address user, address token) external view returns (uint256); + + function balancesOfTokens(address user, address[] calldata tokens) external view returns (uint256[] memory); + + // NOTE: added a possibility to batch-deposit + function deposit(ITradingStructs.Intent calldata intent) external payable; + + function withdraw(ITradingStructs.Intent calldata intent, bytes calldata additionalAuthData) external; + + function settle(ITradingStructs.Outcome calldata outcome, bytes calldata additionalAuthData) external; + + function liquidate(ITradingStructs.Outcome calldata outcome, bytes calldata additionalAuthData) external; +} diff --git a/src/vault/TradingVaultAuthorizer.sol b/src/vault/TradingVaultAuthorizer.sol new file mode 100644 index 0000000..aab4aea --- /dev/null +++ b/src/vault/TradingVaultAuthorizer.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; + +import {IAuthorizeV2} from "../interfaces/IAuthorizeV2.sol"; +import {ITradingVault, ITradingStructs} from "../interfaces/ITradingVault.sol"; + +contract TradingVaultAuthorizer is IAuthorizeV2 { + using MessageHashUtils for bytes32; + using ECDSA for bytes32; + + error InvalidSignature(); + + address public immutable broker; + + constructor(address broker_) { + broker = broker_; + } + + function authorize(bytes calldata authData) external view returns (bool) { + bytes8 mode = bytes8(authData[:8]); + bytes memory authData_ = authData[8:]; + bytes memory data; + bytes memory signature; + + if (mode == ITradingVault.withdraw.selector) { + ITradingStructs.Intent memory intent; + (intent, signature) = abi.decode(authData_, (ITradingStructs.Intent, bytes)); + data = abi.encode(intent); + } else if (mode == ITradingVault.settle.selector) { + ITradingStructs.Outcome memory outcome; + (outcome, signature) = abi.decode(authData_, (ITradingStructs.Outcome, bytes)); + data = abi.encode(outcome, ITradingVault.settle.selector); + } else if (mode == ITradingVault.liquidate.selector) { + ITradingStructs.Outcome memory outcome; + (outcome, signature) = abi.decode(authData_, (ITradingStructs.Outcome, bytes)); + data = abi.encode(outcome, ITradingVault.liquidate.selector); + } else { + revert IAuthorizeV2.Unauthorized(authData); + } + + _requireValidSigner(broker, data, signature); + return true; + } + + function _requireValidSigner(address expectedSigner, bytes memory message, bytes memory sig) internal view { + bytes32 hash = keccak256(message); + if (expectedSigner.code.length == 0) { + address recovered = hash.toEthSignedMessageHash().recover(sig); + require(recovered == expectedSigner, InvalidSignature()); + } else { + bytes4 value = IERC1271(expectedSigner).isValidSignature(hash, sig); + require(value == IERC1271.isValidSignature.selector, InvalidSignature()); + } + } +} diff --git a/src/vault/TravingVault.sol b/src/vault/TravingVault.sol new file mode 100644 index 0000000..3a2c10d --- /dev/null +++ b/src/vault/TravingVault.sol @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {ITradingStructs, ITradingVault} from "../interfaces/ITradingVault.sol"; +import {IAuthorizeV2} from "../interfaces/IAuthorizeV2.sol"; +import {IAuthorizableV2} from "../interfaces/IAuthorizableV2.sol"; + +contract TradingVault is ITradingVault, IAuthorizableV2, ReentrancyGuard, Ownable2Step { + /// @dev Using SafeERC20 to support non fully ERC20-compliant tokens, + /// that may not return a boolean value on success. + using SafeERC20 for IERC20; + + mapping(address user => mapping(address token => uint256 balance)) internal _balances; + mapping(address user => uint256 session) internal _nonces; + + address public broker; + IAuthorizeV2 public authorizer; + + modifier notZeroAddress(address addr) { + require(addr != address(0), InvalidAddress()); + _; + } + + constructor(address owner, address broker_, IAuthorizeV2 authorizer_) Ownable(owner) { + broker = broker_; + authorizer = authorizer_; + } + + // ---------- View functions ---------- + + function balanceOf(address user, address token) external view returns (uint256) { + return _balances[user][token]; + } + + function balancesOfTokens(address user, address[] calldata tokens) external view returns (uint256[] memory) { + uint256[] memory balances = new uint256[](tokens.length); + for (uint256 i = 0; i < tokens.length; i++) { + balances[i] = _balances[user][tokens[i]]; + } + return balances; + } + + // ---------- Owner functions ---------- + + function setBroker(address broker_) external onlyOwner { + broker = broker_; + } + + function setAuthorizer(IAuthorizeV2 newAuthorizer) external onlyOwner { + if (address(newAuthorizer) == address(0)) { + revert InvalidAddress(); + } + + authorizer = newAuthorizer; + emit AuthorizerChanged(newAuthorizer); + } + + // ---------- Write functions ---------- + + function deposit(ITradingStructs.Intent calldata intent) external payable notZeroAddress(intent.trader) { + address sender = msg.sender; + address recipient = intent.trader; + uint256 nonce = _nonces[recipient]; + require(nonce == intent.nonce, NonceMismatch(nonce, intent.nonce)); + + _nonces[recipient]++; + + for (uint256 i = 0; i < intent.allocations.length; i++) { + address token = intent.allocations[i].asset; + uint256 amount = intent.allocations[i].amount; + require(amount > 0, InvalidAmount()); + + if (token == address(0)) { + require(msg.value == amount, IncorrectValue()); + _balances[recipient][address(0)] += amount; + } else { + require(msg.value == 0, IncorrectValue()); + _balances[recipient][token] += amount; + IERC20(token).safeTransferFrom(sender, address(this), amount); + } + + emit Deposited(recipient, token, amount); + } + } + + function withdraw( + ITradingStructs.Intent calldata intent, + bytes calldata additionalAuthData + ) external notZeroAddress(intent.trader) { + address account = intent.trader; + uint256 nonce = _nonces[account]; + require(nonce == intent.nonce, NonceMismatch(nonce, intent.nonce)); + + bytes memory authData = abi.encodePacked( + ITradingVault.withdraw.selector, + abi.encode(intent, additionalAuthData) + ); + require(authorizer.authorize(authData), IAuthorizeV2.Unauthorized(authData)); + + // TODO: do we need seasons support here? + // if ( + // !_isWithdrawalGracePeriodActive( + // latestSetAuthorizerTimestamp, uint64(block.timestamp), WITHDRAWAL_GRACE_PERIOD + // ) && !authorizer.authorize(msg.sender, token, amount) + // ) { + // revert IAuthorize.Unauthorized(msg.sender, token, amount); + // } + + _nonces[account]++; + + for (uint256 i = 0; i < intent.allocations.length; i++) { + address asset = intent.allocations[i].asset; + uint256 amount = intent.allocations[i].amount; + uint256 currentBalance = _balances[account][asset]; + require(currentBalance >= amount, InsufficientBalance(asset, amount, currentBalance)); + + _balances[account][asset] -= amount; + + if (asset == address(0)) { + /// @dev using `call` instead of `transfer` to overcome 2300 gas ceiling that could make it revert with some AA wallets + (bool success, ) = account.call{value: amount}(""); + require(success, NativeTransferFailed()); + } else { + IERC20(asset).safeTransfer(account, amount); + } + + emit Withdrawn(account, asset, amount); + } + } + + function settle(ITradingStructs.Outcome calldata outcome, bytes calldata additionalAuthData) external { + uint256 nonce = _nonces[outcome.trader]; + require(nonce == outcome.nonce, NonceMismatch(nonce, outcome.nonce)); + bytes memory authData = abi.encodePacked( + ITradingVault.settle.selector, + abi.encode(outcome, additionalAuthData) + ); + require(authorizer.authorize(authData), IAuthorizeV2.Unauthorized(authData)); + + _nonces[outcome.trader]++; + + _sendAssets(outcome.trader, broker, outcome.brokerFundingDestination, outcome.traderGives); + _sendAssets(broker, outcome.trader, outcome.traderFundingDestination, outcome.brokerGives); + + emit Settled(outcome.trader, nonce - 1); + } + + function liquidate( + ITradingStructs.Outcome calldata outcome, + bytes calldata additionalAuthData + ) external notZeroAddress(outcome.trader) { + require( + outcome.traderFundingDestination == ITradingStructs.FundingLocation.TradingVault, + InvalidFundingLocation() + ); + require(outcome.brokerGives.length == 0, InvalidAssetOutcome()); + uint256 nonce = _nonces[outcome.trader]; + require(nonce == outcome.nonce, NonceMismatch(nonce, outcome.nonce)); + + bytes memory authData = abi.encodePacked( + ITradingVault.liquidate.selector, + abi.encode(outcome, additionalAuthData) + ); + require(authorizer.authorize(authData), IAuthorizeV2.Unauthorized(authData)); + + _nonces[outcome.trader]++; + + _sendAssets(outcome.trader, broker, outcome.brokerFundingDestination, outcome.traderGives); + + emit Liquidated(outcome.trader, nonce - 1); + } + + // ---------- Internal functions ---------- + + function _checkAndVaultSwap( + address sender, + address receiver, + ITradingStructs.Allocation memory alloc + ) internal virtual { + uint256 balance = _balances[sender][alloc.asset]; + require(balance >= alloc.amount, InsufficientBalance(alloc.asset, alloc.amount, balance)); + + _balances[sender][alloc.asset] -= alloc.amount; + _balances[receiver][alloc.asset] += alloc.amount; + } + + function _checkAndVaultSendAccount( + address sender, + address receiver, + ITradingStructs.Allocation memory alloc + ) internal virtual { + uint256 balance = _balances[sender][alloc.asset]; + require(balance >= alloc.amount, InsufficientBalance(alloc.asset, alloc.amount, balance)); + + _balances[sender][alloc.asset] -= alloc.amount; + _balances[receiver][alloc.asset] += alloc.amount; + } + + function _accountSendVault( + address sender, + address receiver, + ITradingStructs.Allocation memory alloc + ) internal virtual { + IERC20(alloc.asset).safeTransferFrom(sender, address(this), alloc.amount); + _balances[receiver][alloc.asset] += alloc.amount; + } + + function _accountSwap(address sender, address receiver, ITradingStructs.Allocation memory alloc) internal virtual { + IERC20(alloc.asset).safeTransferFrom(sender, receiver, alloc.amount); + } + + function _sendAssets( + address sender, + address receiver, + ITradingStructs.FundingLocation receiverFD, + ITradingStructs.Funding[] memory senderGives + ) internal { + for (uint256 i = 0; i < senderGives.length; i++) { + ITradingStructs.Funding memory senderFunding = senderGives[i]; + if (senderFunding.source == ITradingStructs.FundingLocation.TradingVault) { + if (receiverFD == ITradingStructs.FundingLocation.TradingVault) { + _checkAndVaultSwap(sender, receiver, senderFunding.allocation); + } else { + _checkAndVaultSendAccount(sender, receiver, senderFunding.allocation); + } + } else { + if (receiverFD == ITradingStructs.FundingLocation.TradingVault) { + _accountSendVault(sender, receiver, senderFunding.allocation); + } else { + _accountSwap(sender, receiver, senderFunding.allocation); + } + } + } + } +}