diff --git a/contracts/proxies/LiquidStakingProxy/ILiquidStakingProxy.sol b/contracts/proxies/LiquidStakingProxy/ILiquidStakingProxy.sol new file mode 100644 index 0000000..3e3aae5 --- /dev/null +++ b/contracts/proxies/LiquidStakingProxy/ILiquidStakingProxy.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/** + * @title ILiquidStakingProxy + * @notice Interface for a TAC cross-chain proxy handling TON-initiated liquid staking flows on EVM. + * @dev Exposes entrypoints invoked by the TAC Cross-Chain Layer to stake/unstake via native precompiles + * and to withdraw/bridge assets back to TON. + */ +interface ILiquidStakingProxy { + /** + * @notice Thrown when a zero address is provided where a valid address is required. + */ + error InvalidAddress(); + + /** + * @notice Thrown when the provided amount is zero or otherwise invalid. + */ + error InvalidAmount(); + + /** + * @notice Thrown when sending native TAC to the Smart Account fails. + * @param smartAccount The Smart Account address that failed to receive funds. + * @param reason Low-level return data describing the failure. + */ + error SendTacToSmartAccountFailed(address smartAccount, bytes reason); + + /** + * @notice Thrown when the Liquid Staking precompile call fails. + */ + error LiquidStakeFailed(); + + /** + * @notice Thrown when staking to LP via the precompile fails. + */ + error StakeToLPFailed(); + + /** + * @notice Thrown when there is no native TAC balance available to withdraw from the Smart Account. + */ + error NoBalanceToWithdraw(); + + /** + * @notice Emitted after a TON-initiated liquid stake is executed and gTAC is queued for bridging. + * @param tvmCaller TON account identifier that initiated the request. + * @param callerSmartAccount TAC Smart Account (SA) derived for the caller. + * @param stakedAmount Native TAC amount provided for liquid staking. + * @param receivedAmount Amount of gTAC (ERC-20) minted and pulled from the SA. + */ + event LiquidStaked( + string indexed tvmCaller, + address callerSmartAccount, + uint256 stakedAmount, + uint256 receivedAmount + ); + + /** + * @notice Emitted after a TON-initiated liquid unstake request is submitted. + * @param tvmCaller TON account identifier that initiated the request. + * @param callerSmartAccount TAC Smart Account (SA) used as delegator for unstake. + * @param amount gTAC (ERC-20) amount burned to initiate liquid unstaking. + * @param completionTime UNIX timestamp when native unbonding completes. + */ + event LiquidUnstaked(string indexed tvmCaller, address callerSmartAccount, uint256 amount, int64 completionTime); + + /** + * @notice Processes a TON-initiated liquid staking request and bridges minted LST (gTAC, ERC-20) back to TON. + * @dev Processes a TON-initiated liquid stake by funding the caller’s Smart Account (SA), executing + * LiquidStakingI.liquidStake from the SA, pulling the minted gTAC to this contract, and queuing it for + * bridging back to TON. + * + * Requirements: + * - Caller must be the Cross-Chain Layer (_onlyCrossChainLayer). + * - `params` must ABI-encode a non-zero `uint256 amount`. + * + * Effects: + * - Mints gTAC to the SA via the native Liquid Staking precompile and schedules those gTAC for return to TON. + * - Emits {LiquidStaked} with TON caller, SA, staked amount, and minted gTAC. + * + * @param tacHeader TAC header carrying TON routing context (e.g. tvmCaller, shardsKey). + * @param params ABI-encoded `(uint256 amount)` — the native amount to liquid-stake. + */ + function liquidStake(bytes calldata tacHeader, bytes calldata params) external payable; + + /** + * @notice Processes a TON-initiated liquid unstake request for gTAC (ERC-20). + * @dev Transfers the specified gTAC amount to the caller’s Smart Account (SA) and executes + * `LiquidStakingI.liquidUnstake` from the SA, returning the unbonding completion time. + * + * Requirements: + * - Caller must be the Cross-Chain Layer (_onlyCrossChainLayer). + * - `params` must ABI-encode a non-zero `uint256 amount`. + * + * Effects: + * - Burns the provided gTAC via the native Liquid Staking precompile and schedules native unbonding. + * - Emits {LiquidUnstaked} with TON caller, SA, unstaked amount, and `completionTime`. + * + * @param tacHeader TAC header carrying TON routing context (e.g. tvmCaller, shardsKey). + * @param params ABI-encoded `(uint256 amount)` — the gTAC amount to liquid-unstake. + */ + function liquidUnstake(bytes calldata tacHeader, bytes calldata params) external; + + /** + * @notice Withdraws all available native TAC from the caller’s Smart Account (SA) and bridges it back to TON. + * @dev Fetches the SA for `tvmCaller`, transfers its full native balance to this contract, and enqueues it for + * bridging to TON. + * + * Requirements: + * - Caller must be the Cross-Chain Layer (_onlyCrossChainLayer). + * - SA must have a positive native TAC balance. + * + * Effects: + * - Moves native TAC from the SA to this contract and schedules it for return to TON. + * + * @param _tacHeader TAC header carrying TON routing context (e.g. tvmCaller, shardsKey). + */ + function withdrawFromAccount(bytes calldata _tacHeader, bytes calldata) external; +} diff --git a/contracts/proxies/LiquidStakingProxy/LiquidStakingProxy.sol b/contracts/proxies/LiquidStakingProxy/LiquidStakingProxy.sol new file mode 100644 index 0000000..a2137bb --- /dev/null +++ b/contracts/proxies/LiquidStakingProxy/LiquidStakingProxy.sol @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +import {TacProxyV1Upgradeable} from "@tonappchain/evm-ccl/contracts/proxies/TacProxyV1Upgradeable.sol"; +import {TacHeaderV1, OutMessageV1, TokenAmount, NFTAmount} from "@tonappchain/evm-ccl/contracts/core/Structs.sol"; + +import {ISAFactory} from "@tonappchain/evm-ccl/contracts/smart-account/interfaces/ISAFactory.sol"; +import {ITacSmartAccount} from "@tonappchain/evm-ccl/contracts/smart-account/interfaces/ITacSmartAccount.sol"; + +import {LiquidStakingI, LIQUIDSTAKING_PRECOMPILE_ADDRESS} from "./precompiles/liquidstake/LiquidStakingI.sol"; +import {IERC20} from "./precompiles/erc20/IERC20.sol"; +import {ILiquidStakingProxy} from "./ILiquidStakingProxy.sol"; + +/** + * @title LiquidStakingProxy + * @notice TAC cross-chain proxy that handles TON-initiated liquid staking flows on EVM: + * funds the caller’s Smart Account (SA), executes native liquid staking precompiles, + * and bridges minted gTAC / withdrawn native TAC back to TON. + * @dev Upgradeable via UUPS; gated by the Cross-Chain Layer for entrypoints. + */ +contract LiquidStakingProxy is ILiquidStakingProxy, TacProxyV1Upgradeable, Ownable2StepUpgradeable, UUPSUpgradeable { + ISAFactory public saFactory; + IERC20 public liquidTacToken; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address crossChainLayer_, address saFactory_, address liquidTacToken_, address owner_) { + initialize(crossChainLayer_, saFactory_, liquidTacToken_, owner_); + _disableInitializers(); + } + + /** + * @notice Initializes the proxy and core dependencies. + * @dev Must be called exactly once. Sets Cross-Chain Layer, SA factory, gTAC token and owner. + * @param crossChainLayer_ Address of the TAC Cross-Chain Layer (TON → EVM gateway). + * @param saFactory_ Smart Account factory used to derive/create caller SAs. + * @param liquidTacToken_ ERC-20 mapping of the liquid bond denom (gTAC) on this chain. + * @param owner_ Initial owner for UUPS authorization. + */ + function initialize( + address crossChainLayer_, + address saFactory_, + address liquidTacToken_, + address owner_ + ) + public + initializer + onlyValidAddress(crossChainLayer_) + onlyValidAddress(saFactory_) + onlyValidAddress(liquidTacToken_) + { + __TacProxyV1Upgradeable_init(crossChainLayer_); + __Ownable2Step_init(); + __Ownable_init(owner_); + __UUPSUpgradeable_init(); + + saFactory = ISAFactory(saFactory_); + liquidTacToken = IERC20(liquidTacToken_); + } + + /** + * @notice Accepts native TAC + */ + receive() external payable {} + + /** + * @inheritdoc ILiquidStakingProxy + */ + function liquidStake(bytes calldata tacHeader, bytes calldata params) external payable _onlyCrossChainLayer { + TacHeaderV1 memory header = _decodeTacHeader(tacHeader); + uint256 amount = _decodeAmount(params); + + (address smartAccountAddress, ) = saFactory.getOrCreateSmartAccount(header.tvmCaller); + ITacSmartAccount smartAccount = ITacSmartAccount(smartAccountAddress); + + _sendTacToSmartAccount(smartAccountAddress, amount); + + bytes memory ret = smartAccount.execute( + LIQUIDSTAKING_PRECOMPILE_ADDRESS, + 0, + abi.encodeWithSelector(LiquidStakingI.liquidStake.selector, smartAccountAddress, amount) + ); + bool success = abi.decode(ret, (bool)); + require(success, LiquidStakeFailed()); + + uint256 erc20Balance = liquidTacToken.balanceOf(smartAccountAddress); + _withdrawTacERC20FromSmartAccount(smartAccount, erc20Balance); + liquidTacToken.approve(_getCrossChainLayerAddress(), erc20Balance); + _bridgeTacERC20ToTon(header, erc20Balance); + + emit LiquidStaked(header.tvmCaller, smartAccountAddress, amount, erc20Balance); + } + + /** + * @inheritdoc ILiquidStakingProxy + */ + function liquidUnstake(bytes calldata tacHeader, bytes calldata params) external _onlyCrossChainLayer { + TacHeaderV1 memory header = _decodeTacHeader(tacHeader); + uint256 amount = _decodeAmount(params); + + (address smartAccountAddress, ) = saFactory.getOrCreateSmartAccount(header.tvmCaller); + ITacSmartAccount smartAccount = ITacSmartAccount(smartAccountAddress); + + liquidTacToken.transfer(smartAccountAddress, amount); + + bytes memory ret = smartAccount.execute( + LIQUIDSTAKING_PRECOMPILE_ADDRESS, + 0, + abi.encodeWithSelector(LiquidStakingI.liquidUnstake.selector, smartAccountAddress, amount) + ); + int64 completionTime = abi.decode(ret, (int64)); + + emit LiquidUnstaked(header.tvmCaller, smartAccountAddress, amount, completionTime); + } + + /** + * @notice UUPS authorization hook for upgrades. + * @dev Restricts upgrades to the current owner. + * @param newImplementation Address of the new implementation. + */ + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} // solhint-disable no-empty-blocks + + /** + * @inheritdoc ILiquidStakingProxy + */ + function withdrawFromAccount(bytes calldata _tacHeader, bytes calldata) external _onlyCrossChainLayer { + TacHeaderV1 memory tacHeader = _decodeTacHeader(_tacHeader); + + (address sa, ) = saFactory.getOrCreateSmartAccount(tacHeader.tvmCaller); + ITacSmartAccount smartAccount = ITacSmartAccount(sa); + + uint256 nativeBalance = address(smartAccount).balance; + require(nativeBalance > 0, NoBalanceToWithdraw()); + + _withdrawTacFromSmartAccount(smartAccount, nativeBalance); + _bridgeTacToTon(tacHeader, nativeBalance); + } + + /** + * @notice Decodes a single uint256 amount from ABI-encoded params. + * @dev Reverts with {InvalidAmount} if the decoded amount is zero. + * @param params ABI-encoded `(uint256 amount)`. + * @return amount Decoded non-zero amount. + */ + function _decodeAmount(bytes calldata params) private pure returns (uint256 amount) { + amount = abi.decode(params, (uint256)); + require(amount != 0, InvalidAmount()); + } + + /** + * @notice Bridges gTAC (ERC-20) back to TON for the given TON caller context. + * @dev Packages a token-bridge message with the provided amount; custody/transfer is handled by CCL. + * @param tacHeader TAC header carrying TON routing context (e.g. tvmCaller, shardsKey). + * @param amount Amount of gTAC to bridge to TON. + */ + function _bridgeTacERC20ToTon(TacHeaderV1 memory tacHeader, uint256 amount) private { + TokenAmount[] memory tokenAmounts = new TokenAmount[](1); + tokenAmounts[0] = TokenAmount(address(liquidTacToken), amount); + + OutMessageV1 memory outMessage = OutMessageV1({ + shardsKey: tacHeader.shardsKey, + tvmTarget: tacHeader.tvmCaller, + tvmPayload: "", + tvmProtocolFee: 0, + tvmExecutorFee: 0, + tvmValidExecutors: new string[](0), + toBridge: tokenAmounts, + toBridgeNFT: new NFTAmount[](0) + }); + _sendMessageV1(outMessage, 0); + } + + /** + * @notice Bridges native TAC back to TON for the given TON caller context. + * @dev Sends a value-bridge message; value is provided as the second argument to the CCL dispatcher. + * @param tacHeader TAC header carrying TON routing context (e.g. tvmCaller, shardsKey). + * @param amount Native TAC amount to bridge to TON. + */ + function _bridgeTacToTon(TacHeaderV1 memory tacHeader, uint256 amount) private { + OutMessageV1 memory outMessage = OutMessageV1({ + shardsKey: tacHeader.shardsKey, + tvmTarget: tacHeader.tvmCaller, + tvmPayload: "", + tvmProtocolFee: 0, + tvmExecutorFee: 0, + tvmValidExecutors: new string[](0), + toBridge: new TokenAmount[](0), + toBridgeNFT: new NFTAmount[](0) + }); + _sendMessageV1(outMessage, amount); + } + + /** + * @notice Sends native TAC to the target Smart Account (SA) using a plain call with value. + * @dev Reverts with {SendTacToSmartAccountFailed} if the SA rejects or reverts the call. + * @param smartAccount Target SA address to receive `amount`. + * @param amount Native TAC amount to forward. + */ + function _sendTacToSmartAccount(address smartAccount, uint256 amount) private { + (bool success, bytes memory result) = smartAccount.call{value: amount}(""); + require(success, SendTacToSmartAccountFailed(smartAccount, result)); + } + + /** + * @notice Withdraws native TAC from the Smart Account (SA) to this contract. + * @dev Executes a value-bearing call via SA’s `execute`, pulling `amount` back to this proxy. + * @param smartAccount The Smart Account to withdraw from. + * @param amount Native TAC amount to pull from the SA. + */ + function _withdrawTacFromSmartAccount(ITacSmartAccount smartAccount, uint256 amount) private { + smartAccount.execute( + address(this), + amount, + "" // empty payload, we just want to withdraw the amount + ); + } + + /** + * @notice Withdraws gTAC (ERC-20) from the Smart Account (SA) to this contract. + * @dev Invokes ERC-20 `transfer` via SA’s `execute`, moving `amount` gTAC to this proxy. + * @param smartAccount The Smart Account to withdraw from. + * @param amount gTAC amount to pull from the SA. + */ + function _withdrawTacERC20FromSmartAccount(ITacSmartAccount smartAccount, uint256 amount) private { + smartAccount.execute( + address(liquidTacToken), + 0, + abi.encodeWithSelector(IERC20.transfer.selector, address(this), amount) + ); + } + + /** + * @notice Ensures the provided address is non-zero. + * @dev Reverts with {InvalidAddress} if `address_` is the zero address. + * @param address_ Address to validate. + */ + modifier onlyValidAddress(address address_) { + require(address_ != address(0), InvalidAddress()); + _; + } +} diff --git a/contracts/proxies/LiquidStakingProxy/mocks/LiquidStakingMock.sol b/contracts/proxies/LiquidStakingProxy/mocks/LiquidStakingMock.sol new file mode 100644 index 0000000..2771011 --- /dev/null +++ b/contracts/proxies/LiquidStakingProxy/mocks/LiquidStakingMock.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {LiquidTacToken} from "./LiquidTacToken.sol"; + +contract LiquidStakingMock { + uint64 public constant UNBONDING_TIME = 1 hours; + + LiquidTacToken public immutable gTAC; + + constructor(address liquidTacToken_) { + gTAC = LiquidTacToken(liquidTacToken_); + } + + function liquidStake(address delegatorAddress, uint256 amount) external returns (bool success) { + require(amount != 0, "Invalid amount"); + gTAC.mint(delegatorAddress, amount); + return true; + } + + function liquidUnstake(address delegatorAddress, uint256 amount) external returns (int64 completionTime) { + gTAC.burnFrom(delegatorAddress, amount); + completionTime = int64(int(block.timestamp + UNBONDING_TIME)); + } +} diff --git a/contracts/proxies/LiquidStakingProxy/mocks/LiquidTacToken.sol b/contracts/proxies/LiquidStakingProxy/mocks/LiquidTacToken.sol new file mode 100644 index 0000000..4e619f9 --- /dev/null +++ b/contracts/proxies/LiquidStakingProxy/mocks/LiquidTacToken.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract LiquidTacToken is ERC20, AccessControl { + bytes32 public constant LIQUID_STAKING = keccak256("LIQUID_STAKING"); + + constructor(address defaultAdmin) ERC20("gTAC", "gTAC") { + _grantRole(DEFAULT_ADMIN_ROLE, defaultAdmin); + } + + function mint(address to, uint256 amount) public onlyRole(LIQUID_STAKING) { + _mint(to, amount); + } + + function burnFrom(address from, uint256 amount) external onlyRole(LIQUID_STAKING) { + _burn(from, amount); + } +} diff --git a/contracts/proxies/LiquidStakingProxy/precompiles/authorization/AuthorizationI.sol b/contracts/proxies/LiquidStakingProxy/precompiles/authorization/AuthorizationI.sol new file mode 100644 index 0000000..5cd703e --- /dev/null +++ b/contracts/proxies/LiquidStakingProxy/precompiles/authorization/AuthorizationI.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity >=0.8.17; + +/// @author Evmos Team +/// @title Authorization Interface +/// @dev The interface through which solidity contracts will interact with smart contract approvals. +interface AuthorizationI { + /// @dev Approves a list of Cosmos or IBC transactions with a specific amount of tokens. + /// @param grantee The contract address which will have an authorization to spend the origin funds. + /// @param amount The amount of tokens to be spent + /// @param methods The message type URLs of the methods to approve. + /// @return approved Boolean value to indicate if the approval was successful. + function approve(address grantee, uint256 amount, string[] calldata methods) external returns (bool approved); + + /// @dev Revokes a list of Cosmos transactions. + /// @param grantee The contract address which will have its allowances revoked. + /// @param methods The message type URLs of the methods to revoke. + /// @return revoked Boolean value to indicate if the revocation was successful. + function revoke(address grantee, string[] calldata methods) external returns (bool revoked); + + /// @dev Increase the allowance of a given spender by a specific amount of tokens for IBC + /// transfer methods or staking. + /// @param grantee The contract address which allowance will be increased. + /// @param amount The amount of tokens to be spent. + /// @param methods The message type URLs of the methods to approve. + /// @return approved Boolean value to indicate if the approval was successful. + function increaseAllowance( + address grantee, + uint256 amount, + string[] calldata methods + ) external returns (bool approved); + + /// @dev Decreases the allowance of a given spender by a specific amount of tokens for IBC + /// transfer methods or staking. + /// @param grantee The contract address which allowance will be decreased. + /// @param amount The amount of tokens to be spent. + /// @param methods The message type URLs of the methods to approve. + /// @return approved Boolean value to indicate if the approval was successful. + function decreaseAllowance( + address grantee, + uint256 amount, + string[] calldata methods + ) external returns (bool approved); + + /// @dev Returns the remaining number of tokens that spender will be allowed to spend + /// on behalf of the owner through IBC transfer methods or staking. This is zero by default. + /// @param grantee The contract address which has the Authorization. + /// @param granter The account address that grants an Authorization. + /// @param method The message type URL of the methods for which the approval should be queried. + /// @return remaining The remaining number of tokens available to be spent. + function allowance( + address grantee, + address granter, + string calldata method + ) external view returns (uint256 remaining); + + /// @dev This event is emitted when the allowance of a granter is set by a call to the approve method. + /// The value field specifies the new allowance and the methods field holds the information for which methods + /// the approval was set. + /// @param grantee The contract address that received an Authorization from the granter. + /// @param granter The account address that granted an Authorization. + /// @param methods The message type URLs of the methods for which the approval is set. + /// @param value The amount of tokens approved to be spent. + event Approval(address indexed grantee, address indexed granter, string[] methods, uint256 value); + + /// @dev This event is emitted when an owner revokes a spender's allowance. + /// @param grantee The contract address that has it's Authorization revoked. + /// @param granter The account address of the granter. + /// @param methods The message type URLs of the methods for which the approval is set. + event Revocation(address indexed grantee, address indexed granter, string[] methods); + + /// @dev This event is emitted when the allowance of a granter is changed by a call to the decrease or increase + /// allowance method. The values field specifies the new allowances and the methods field holds the + /// information for which methods the approval was set. + /// @param grantee The contract address for which the allowance changed. + /// @param granter The account address of the granter. + /// @param methods The message type URLs of the methods for which the approval is set. + /// @param values The amounts of tokens approved to be spent. + event AllowanceChange(address indexed grantee, address indexed granter, string[] methods, uint256[] values); +} diff --git a/contracts/proxies/LiquidStakingProxy/precompiles/common/Types.sol b/contracts/proxies/LiquidStakingProxy/precompiles/common/Types.sol new file mode 100644 index 0000000..4e512b0 --- /dev/null +++ b/contracts/proxies/LiquidStakingProxy/precompiles/common/Types.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity >=0.8.17; + +/// @dev Allocation represents a single allocation for an IBC fungible token transfer. +struct ICS20Allocation { + string sourcePort; + string sourceChannel; + Coin[] spendLimit; + string[] allowList; + string[] allowedPacketData; +} + +/// @dev Dec represents a fixed point decimal value. The value is stored as an integer, and the +/// precision is stored as a uint8. The value is multiplied by 10^precision to get the actual value. +struct Dec { + uint256 value; + uint8 precision; +} + +/// @dev Coin is a struct that represents a token with a denomination and an amount. +struct Coin { + string denom; + uint256 amount; +} + +/// @dev DecCoin is a struct that represents a token with a denomination, an amount and a precision. +struct DecCoin { + string denom; + uint256 amount; + uint8 precision; +} + +/// @dev PageResponse is a struct that represents a page response. +struct PageResponse { + bytes nextKey; + uint64 total; +} + +/// @dev PageRequest is a struct that represents a page request. +struct PageRequest { + bytes key; + uint64 offset; + uint64 limit; + bool countTotal; + bool reverse; +} + +/// @dev Height is a monotonically increasing data type +/// that can be compared against another Height for the purposes of updating and +/// freezing clients +/// +/// Normally the RevisionHeight is incremented at each height while keeping +/// RevisionNumber the same. However some consensus algorithms may choose to +/// reset the height in certain conditions e.g. hard forks, state-machine +/// breaking changes In these cases, the RevisionNumber is incremented so that +/// height continues to be monotonically increasing even as the RevisionHeight +/// gets reset +struct Height { + // the revision that the client is currently on + uint64 revisionNumber; + // the height within the given revision + uint64 revisionHeight; +} diff --git a/contracts/proxies/LiquidStakingProxy/precompiles/erc20/IERC20.sol b/contracts/proxies/LiquidStakingProxy/precompiles/erc20/IERC20.sol new file mode 100644 index 0000000..66c4e4d --- /dev/null +++ b/contracts/proxies/LiquidStakingProxy/precompiles/erc20/IERC20.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.6.0) (token/ERC20/IERC20.sol) + +pragma solidity ^0.8.0; + +/** + * @dev Interface of the ERC20 standard as defined in the EIP. + */ +interface IERC20 { + /** + * @dev Emitted when `value` tokens are moved from one account (`from`) to + * another (`to`). + * + * Note that `value` may be zero. + */ + event Transfer(address indexed from, address indexed to, uint256 value); + + /** + * @dev Emitted when the allowance of a `spender` for an `owner` is set by + * a call to {approve}. `value` is the new allowance. + */ + event Approval(address indexed owner, address indexed spender, uint256 value); + + /** + * @dev Returns the amount of tokens in existence. + */ + function totalSupply() external view returns (uint256); + + /** + * @dev Returns the amount of tokens owned by `account`. + */ + function balanceOf(address account) external view returns (uint256); + + /** + * @dev Moves `amount` tokens from the caller's account to `to`. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * Emits a {Transfer} event. + */ + function transfer(address to, uint256 amount) external returns (bool); + + /** + * @dev Returns the remaining number of tokens that `spender` will be + * allowed to spend on behalf of `owner` through {transferFrom}. This is + * zero by default. + * + * This value changes when {approve} or {transferFrom} are called. + */ + function allowance(address owner, address spender) external view returns (uint256); + + /** + * @dev Sets `amount` as the allowance of `spender` over the caller's tokens. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * IMPORTANT: Beware that changing an allowance with this method brings the risk + * that someone may use both the old and the new allowance by unfortunate + * transaction ordering. One possible solution to mitigate this race + * condition is to first reduce the spender's allowance to 0 and set the + * desired value afterwards: + * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 + * + * Emits an {Approval} event. + */ + function approve(address spender, uint256 amount) external returns (bool); + + /** + * @dev Moves `amount` tokens from `from` to `to` using the + * allowance mechanism. `amount` is then deducted from the caller's + * allowance. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * Emits a {Transfer} event. + */ + function transferFrom(address from, address to, uint256 amount) external returns (bool); +} diff --git a/contracts/proxies/LiquidStakingProxy/precompiles/liquidstake/LiquidStakingI.sol b/contracts/proxies/LiquidStakingProxy/precompiles/liquidstake/LiquidStakingI.sol new file mode 100644 index 0000000..4bd49e8 --- /dev/null +++ b/contracts/proxies/LiquidStakingProxy/precompiles/liquidstake/LiquidStakingI.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "../authorization/AuthorizationI.sol" as authorization; +import "../common/Types.sol"; + +/// @dev The LiquidStakingI contract's address. +address constant LIQUIDSTAKING_PRECOMPILE_ADDRESS = 0x0000000000000000000000000000000000001600; + +/// @dev The LiquidStakingI contract's instance. +LiquidStakingI constant LIQUIDSTAKING_CONTRACT = LiquidStakingI(LIQUIDSTAKING_PRECOMPILE_ADDRESS); + +/// @dev Define all the available liquidstake methods. +string constant MSG_LIQUID_STAKE = "/tac.liquidstake.v1beta1.MsgLiquidStake"; +string constant MSG_LIQUID_UNSTAKE = "/tac.liquidstake.v1beta1.MsgLiquidUnstake"; +string constant MSG_STAKE_TO_LP = "/tac.liquidstake.v1beta1.MsgStakeToLP"; + +struct WhitelistedValidator { + address validatorAddress; + int256 targetWeight; +} + +struct LiquidStakeParams { + string liquidBondDenom; + WhitelistedValidator[] whitelistedValidators; + int256 unstakeFeeRate; + bool lsmDisabled; + int256 minLiquidStakeAmount; + address cwLockedPoolAddress; + address feeAccountAddress; + int256 autocompoundFeeRate; + address whitelistAdminAddress; + bool modulePaused; +} + +struct LiquidStakeUpdatableParams { + int256 unstakeFeeRate; + bool lsmDisabled; + int256 minLiquidStakeAmount; + address cwLockedPoolAddress; + address feeAccountAddress; + int256 autocompoundFeeRate; + address whitelistAdminAddress; +} + +enum ValidatorStatus { + Unspecified, + Active, + Inactive +} + +struct LiquidValidatorState { + address operatorAddress; + int256 weight; + ValidatorStatus status; + int256 delShares; + int256 liquidTokens; +} + +struct NetAmountState { + int256 mintRate; + int256 stkTACTotalSupply; + int256 netAmount; + int256 totalDelShares; + int256 totalLiquidTokens; + int256 totalRemainingRewards; + int256 totalUnbondingBalance; + int256 proxyAccBalance; +} + +/// @dev The interface through which solidity contracts will interact with liquidstaking. +interface LiquidStakingI is authorization.AuthorizationI { + // functions definitions start + function liquidStake(address delegatorAddress, uint256 amount) external returns (bool success); + // dev notes: bool success corresponds to "empty" responce in message server + + function stakeToLP( + address delegatorAddress, + address validatorAddress, + uint256 stakedAmount, + uint256 liquidAmount + ) external returns (bool success); + + function liquidUnstake(address delegatorAddress, uint256 Amount) external returns (int64 completionTime); + + // admin transactions + function updateParams(LiquidStakeUpdatableParams calldata params) external returns (bool success); + + function updateWhitelistedValidators( + WhitelistedValidator[] calldata whitelistedValidators + ) external returns (bool success); + + function setModulePaused(bool isPaused) external returns (bool success); + // functions definitions end + + // view functions/query definitions start + function params() external view returns (LiquidStakeParams calldata); + + function liquidValidators() external view returns (LiquidValidatorState[] calldata); + + function states() external view returns (NetAmountState calldata); + // view functions/query definitions end + + // events definitions start + event LiquidStake(address indexed delegatorAddress, uint256 amount); + + event StakeToLP( + address indexed delegatorAddress, + address indexed validatorAddress, + uint256 stakedAmount, + uint256 liquidAmount + ); + + event LiquidUnstake(address indexed delegatorAddress, uint256 amount); + + event UpdateParams(LiquidStakeUpdatableParams params); + + event UpdateWhitelistedValidator(WhitelistedValidator[] whitelistedValidators); + + event SetModulePaused(bool isPaused); + // events definitions end +} diff --git a/scripts/LiquidStakingProxy/config/mainnetConfig.ts b/scripts/LiquidStakingProxy/config/mainnetConfig.ts new file mode 100644 index 0000000..c1beed3 --- /dev/null +++ b/scripts/LiquidStakingProxy/config/mainnetConfig.ts @@ -0,0 +1,13 @@ +export interface LiquidStakingConfig { + crossChainLayer: string; + smartAccountFactory: string; + liquidTacToken: string | null; + owner: string | null; +} + +export const liquidStakingConfig: LiquidStakingConfig = { + crossChainLayer: "0x9fee01e948353E0897968A3ea955815aaA49f58d", + smartAccountFactory: "0x070820Ed658860f77138d71f74EfbE173775895b", + liquidTacToken: "0xCff690a580d591432e46d37C6221DC2d9BCE4f4a", + owner: null, +}; diff --git a/scripts/LiquidStakingProxy/deployLiquidStakingProxy.ts b/scripts/LiquidStakingProxy/deployLiquidStakingProxy.ts new file mode 100644 index 0000000..73c98bf --- /dev/null +++ b/scripts/LiquidStakingProxy/deployLiquidStakingProxy.ts @@ -0,0 +1,41 @@ +import { ethers, upgrades } from "hardhat"; +import { liquidStakingConfig } from "./config/mainnetConfig"; + +async function main(): Promise { + const CONTRACT_NAME = "LiquidStakingProxy"; + + const LiquidStakingProxyFactory = await ethers.getContractFactory( + CONTRACT_NAME + ); + const liquidStakingProxy = await upgrades.deployProxy( + LiquidStakingProxyFactory, + [ + liquidStakingConfig.crossChainLayer, + liquidStakingConfig.smartAccountFactory, + liquidStakingConfig.liquidTacToken, + liquidStakingConfig.owner, + ], + { + kind: "uups", + constructorArgs: [ + liquidStakingConfig.crossChainLayer, + liquidStakingConfig.smartAccountFactory, + liquidStakingConfig.liquidTacToken, + liquidStakingConfig.owner, + ], + } + ); + await liquidStakingProxy.waitForDeployment(); + + console.log( + "LiquidStakingProxy deployed to:", + await liquidStakingProxy.getAddress() + ); +} + +main() + .then(() => process.exit(0)) + .catch((e) => { + console.error(e); + process.exit(1); + }); diff --git a/test/LiquidStakingProxy/LiquidStakingProxy.fixture.test.ts b/test/LiquidStakingProxy/LiquidStakingProxy.fixture.test.ts new file mode 100644 index 0000000..ee53c85 --- /dev/null +++ b/test/LiquidStakingProxy/LiquidStakingProxy.fixture.test.ts @@ -0,0 +1,82 @@ +import { ethers, network } from "hardhat"; +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; +import { TacLocalTestSdk } from "@tonappchain/evm-ccl"; +import { + LiquidStakingMock, + LiquidStakingProxy, + LiquidTacToken, +} from "../../typechain-types"; + +export const LIQUIDSTAKING_PRECOMPILE = + "0x0000000000000000000000000000000000001600"; +export const ZERO_ADDRESS = `0x${"".padStart(40, "0")}`; +export const ZERO_BN = 0n; +export const ONE_BN = 1n; + +export interface LiquidStakingDependencies { + gTACContract: LiquidTacToken; + liquidStakingMockContract: LiquidStakingMock; + crossChainLayerAddress: string; + saFactoryAddress: string; +} + +export async function deployLiquidStakingDependencies( + testSdk: TacLocalTestSdk, + deployer: SignerWithAddress +): Promise { + const gTACContract = await ethers + .getContractFactory("LiquidTacToken") + .then((f) => f.connect(deployer).deploy(deployer)); + + const liquidStakingMockContract = await ethers + .getContractFactory("LiquidStakingMock") + .then((f) => f.connect(deployer).deploy(gTACContract.target)); + const code = await ethers.provider.getCode( + await liquidStakingMockContract.getAddress() + ); + await network.provider.send("hardhat_setCode", [ + LIQUIDSTAKING_PRECOMPILE, + code, + ]); + + const LIQUID_STAKING = await gTACContract.LIQUID_STAKING(); + await gTACContract.grantRole(LIQUID_STAKING, LIQUIDSTAKING_PRECOMPILE); + + const crossChainLayerAddress = await testSdk.create(ethers.provider); + await setBalance(crossChainLayerAddress, ethers.parseEther("1000")); + const saFactoryAddress = testSdk.getSmartAccountFactoryAddress(); + + return { + gTACContract, + liquidStakingMockContract, + crossChainLayerAddress, + saFactoryAddress, + }; +} + +export async function deployLiquidStakingProxy( + testSdk: TacLocalTestSdk, + deployer: SignerWithAddress +): Promise< + { + liquidStakingProxy: LiquidStakingProxy; + } & LiquidStakingDependencies +> { + const dependencies = await deployLiquidStakingDependencies(testSdk, deployer); + + const liquidStakingProxy = await ethers + .getContractFactory("LiquidStakingProxy") + .then((f) => + f + .connect(deployer) + .deploy( + dependencies.crossChainLayerAddress, + dependencies.saFactoryAddress, + dependencies.gTACContract.target, + deployer + ) + ); + + return { liquidStakingProxy, ...dependencies }; +} diff --git a/test/LiquidStakingProxy/LiquidStakingProxy.helpers.test.ts b/test/LiquidStakingProxy/LiquidStakingProxy.helpers.test.ts new file mode 100644 index 0000000..8208059 --- /dev/null +++ b/test/LiquidStakingProxy/LiquidStakingProxy.helpers.test.ts @@ -0,0 +1,30 @@ +import { SendMessageOutput, TacLocalTestSdk } from "@tonappchain/evm-ccl"; +import { ethers } from "hardhat"; + +export const randInt = (min: number, max: number) => + Math.floor(Math.random() * (max - min + 1)) + min; + +export async function sendMessage( + testSdk: TacLocalTestSdk, + tvmCaller: string, + target: string, + methodName: string, + encodedArguments: string, + amount: bigint +): Promise { + const shardsKey = BigInt(randInt(0, 10000)); + const operationId = ethers.encodeBytes32String(`operation-${shardsKey}`); + + return await testSdk.sendMessage( + shardsKey, + target, + methodName, + encodedArguments, + tvmCaller, + [], + [], + amount, + "0x", + operationId + ); +} diff --git a/test/LiquidStakingProxy/LiquidStakingProxy.initialize.test.ts b/test/LiquidStakingProxy/LiquidStakingProxy.initialize.test.ts new file mode 100644 index 0000000..5ec2aed --- /dev/null +++ b/test/LiquidStakingProxy/LiquidStakingProxy.initialize.test.ts @@ -0,0 +1,163 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { TacLocalTestSdk } from "@tonappchain/evm-ccl"; +import { + LiquidStakingProxy, + LiquidStakingProxy__factory, +} from "../../typechain-types"; +import { + deployLiquidStakingDependencies, + LiquidStakingDependencies, + ZERO_ADDRESS, +} from "./LiquidStakingProxy.fixture.test"; + +describe("Method: initialize: ", () => { + let ownerAccount: SignerWithAddress; + let owner: string; + let liquidStakingProxyFactory: LiquidStakingProxy__factory; + let liquidStakingProxy: LiquidStakingProxy; + + let testSdk: TacLocalTestSdk; + + before(async () => { + [ownerAccount] = await ethers.getSigners(); + owner = ownerAccount.address; + + testSdk = new TacLocalTestSdk(); + liquidStakingProxyFactory = await ethers.getContractFactory( + "LiquidStakingProxy" + ); + }); + + async function deployFixture(): Promise { + const dependencies = await deployLiquidStakingDependencies( + testSdk, + ownerAccount + ); + return dependencies; + } + + describe("When one of parameters is incorrect", () => { + describe("When cross chain layer is zero address", () => { + it("should revert with InvalidAddress", async () => { + const { saFactoryAddress, gTACContract } = await loadFixture( + deployFixture + ); + + await expect( + liquidStakingProxyFactory.deploy( + ZERO_ADDRESS, + saFactoryAddress, + gTACContract.target, + owner + ) + ) + .to.be.revertedWithCustomError( + liquidStakingProxyFactory, + "InvalidAddress" + ) + .withArgs(); + }); + }); + + describe("When smart-account factory is zero address", () => { + it("should revert with InvalidAddress", async () => { + const { crossChainLayerAddress, gTACContract } = await loadFixture( + deployFixture + ); + + await expect( + liquidStakingProxyFactory.deploy( + crossChainLayerAddress, + ZERO_ADDRESS, + gTACContract.target, + owner + ) + ) + .to.be.revertedWithCustomError( + liquidStakingProxyFactory, + "InvalidAddress" + ) + .withArgs(); + }); + }); + + describe("When liquid staking token is zero address", () => { + it("should revert with InvalidAddress", async () => { + const { crossChainLayerAddress, saFactoryAddress } = await loadFixture( + deployFixture + ); + + await expect( + liquidStakingProxyFactory.deploy( + crossChainLayerAddress, + saFactoryAddress, + ZERO_ADDRESS, + owner + ) + ) + .to.be.revertedWithCustomError( + liquidStakingProxyFactory, + "InvalidAddress" + ) + .withArgs(); + }); + }); + + describe("When owner is zero address", () => { + it("should revert with OwnableInvalidOwner", async () => { + const { crossChainLayerAddress, saFactoryAddress, gTACContract } = + await loadFixture(deployFixture); + + await expect( + liquidStakingProxyFactory.deploy( + crossChainLayerAddress, + saFactoryAddress, + gTACContract.target, + ZERO_ADDRESS + ) + ) + .to.be.revertedWithCustomError( + liquidStakingProxyFactory, + "OwnableInvalidOwner" + ) + .withArgs(ZERO_ADDRESS); + }); + }); + }); + + describe("When all parameters correct", () => { + let liquidStakingProxyDependencies: LiquidStakingDependencies; + + before(async () => { + liquidStakingProxyDependencies = await loadFixture(deployFixture); + }); + + it("should success", async () => { + liquidStakingProxy = await liquidStakingProxyFactory.deploy( + liquidStakingProxyDependencies.crossChainLayerAddress, + liquidStakingProxyDependencies.saFactoryAddress, + liquidStakingProxyDependencies.gTACContract.target, + owner + ); + }); + + it("should smart-account factory address be equal to expected", async () => { + expect(await liquidStakingProxy.saFactory()).to.equal( + liquidStakingProxyDependencies.saFactoryAddress + ); + }); + + it("should liquid staking token be equal to expected", async () => { + expect(await liquidStakingProxy.liquidTacToken()).to.equal( + liquidStakingProxyDependencies.gTACContract.target + ); + }); + + it("should owner be equal to expected", async () => { + expect(await liquidStakingProxy.owner()).to.equal(owner); + }); + }); +}); diff --git a/test/LiquidStakingProxy/LiquidStakingProxy.liquidStake.test.ts b/test/LiquidStakingProxy/LiquidStakingProxy.liquidStake.test.ts new file mode 100644 index 0000000..7daa689 --- /dev/null +++ b/test/LiquidStakingProxy/LiquidStakingProxy.liquidStake.test.ts @@ -0,0 +1,91 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { parseEther } from "ethers"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { TacLocalTestSdk } from "@tonappchain/evm-ccl"; +import { + deployLiquidStakingProxy, + ZERO_BN, +} from "./LiquidStakingProxy.fixture.test"; +import { LiquidStakingProxy } from "../../typechain-types"; +import { sendMessage } from "./LiquidStakingProxy.helpers.test"; + +const abiCoder = ethers.AbiCoder.defaultAbiCoder(); + +describe("Method: liquidStake: ", () => { + const TVM_CALLER = "TVM-USER-1"; + const STAKE_AMOUNT = parseEther("1"); + + let ownerAccount: SignerWithAddress; + + let testSdk: TacLocalTestSdk; + + before(async () => { + [ownerAccount] = await ethers.getSigners(); + + testSdk = new TacLocalTestSdk(); + }); + + async function deployFixture(): Promise { + const contracts = await deployLiquidStakingProxy(testSdk, ownerAccount); + return contracts.liquidStakingProxy; + } + + describe("When one of parameters is incorrect", () => { + describe("When the caller is not a cross chain contract", () => { + it("should revert with OnlyCrossChainLayer", async () => { + const liquidStakingProxy = await loadFixture(deployFixture); + + await expect( + liquidStakingProxy.connect(ownerAccount).liquidStake("0x", "0x") + ) + .to.be.revertedWithCustomError( + liquidStakingProxy, + "OnlyCrossChainLayer" + ) + .withArgs(); + }); + }); + + describe("When the passed amount equal zero", () => { + it("should revert with InvalidAmount", async () => { + const liquidStakingProxy = await loadFixture(deployFixture); + + const encoded = abiCoder.encode(["uint256"], [ZERO_BN]); + + await expect( + sendMessage( + testSdk, + TVM_CALLER, + liquidStakingProxy.target as string, + "liquidStake(bytes,bytes)", + encoded, + ZERO_BN + ) + ).to.be.rejectedWith(/ProxyCallError: custom error 0x2c5211c6/); // InvalidAmount() + }); + }); + }); + + describe("When all parameters correct", () => { + let liquidStakingProxy: LiquidStakingProxy; + let encoded: string; + + before(async () => { + liquidStakingProxy = await loadFixture(deployFixture); + encoded = abiCoder.encode(["uint256"], [STAKE_AMOUNT]); + }); + + it("should success", async () => { + await sendMessage( + testSdk, + TVM_CALLER, + liquidStakingProxy.target as string, + "liquidStake(bytes,bytes)", + encoded, + STAKE_AMOUNT + ); + }); + }); +}); diff --git a/test/LiquidStakingProxy/LiquidStakingProxy.liquidUnstake.test.ts b/test/LiquidStakingProxy/LiquidStakingProxy.liquidUnstake.test.ts new file mode 100644 index 0000000..df0491a --- /dev/null +++ b/test/LiquidStakingProxy/LiquidStakingProxy.liquidUnstake.test.ts @@ -0,0 +1,101 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { parseEther } from "ethers"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { TacLocalTestSdk } from "@tonappchain/evm-ccl"; +import { + deployLiquidStakingProxy, + LiquidStakingDependencies, + ZERO_BN, +} from "./LiquidStakingProxy.fixture.test"; +import { LiquidStakingProxy, LiquidTacToken } from "../../typechain-types"; +import { sendMessage } from "./LiquidStakingProxy.helpers.test"; + +const abiCoder = ethers.AbiCoder.defaultAbiCoder(); + +describe("Method: liquidUnstake: ", () => { + const TVM_CALLER = "TVM-USER-1"; + const UNSTAKE_AMOUNT = parseEther("1"); + + let ownerAccount: SignerWithAddress; + + let testSdk: TacLocalTestSdk; + + before(async () => { + [ownerAccount] = await ethers.getSigners(); + + testSdk = new TacLocalTestSdk(); + }); + + async function deployFixture(): Promise< + { liquidStakingProxy: LiquidStakingProxy } & LiquidStakingDependencies + > { + return await deployLiquidStakingProxy(testSdk, ownerAccount); + } + + describe("When one of parameters is incorrect", () => { + describe("When the caller is not a cross chain contract", () => { + it("should revert with OnlyCrossChainLayer", async () => { + const { liquidStakingProxy } = await loadFixture(deployFixture); + + await expect( + liquidStakingProxy.connect(ownerAccount).liquidUnstake("0x", "0x") + ) + .to.be.revertedWithCustomError( + liquidStakingProxy, + "OnlyCrossChainLayer" + ) + .withArgs(); + }); + }); + + describe("When the passed amount equal zero", () => { + it("should revert with InvalidAmount", async () => { + const { liquidStakingProxy } = await loadFixture(deployFixture); + + const encoded = abiCoder.encode(["uint256"], [ZERO_BN]); + + await expect( + sendMessage( + testSdk, + TVM_CALLER, + liquidStakingProxy.target as string, + "liquidUnstake(bytes,bytes)", + encoded, + ZERO_BN + ) + ).to.be.rejectedWith(/ProxyCallError: custom error 0x2c5211c6/); // InvalidAmount() + }); + }); + }); + + describe("When all parameters correct", () => { + let liquidStakingProxy: LiquidStakingProxy; + let liquidTacToken: LiquidTacToken; + let encoded: string; + + before(async () => { + const snapshot = await loadFixture(deployFixture); + liquidStakingProxy = snapshot.liquidStakingProxy; + liquidTacToken = snapshot.gTACContract; + + encoded = abiCoder.encode(["uint256"], [UNSTAKE_AMOUNT]); + + const LIQUID_STAKING = await liquidTacToken.LIQUID_STAKING(); + await liquidTacToken.grantRole(LIQUID_STAKING, ownerAccount); + await liquidTacToken.mint(liquidStakingProxy.target, UNSTAKE_AMOUNT); + }); + + it("should success", async () => { + await sendMessage( + testSdk, + TVM_CALLER, + liquidStakingProxy.target as string, + "liquidUnstake(bytes,bytes)", + encoded, + ZERO_BN + ); + }); + }); +}); diff --git a/test/LiquidStakingProxy/LiquidStakingProxy.withdrawFromAccount.test.ts b/test/LiquidStakingProxy/LiquidStakingProxy.withdrawFromAccount.test.ts new file mode 100644 index 0000000..11a6edd --- /dev/null +++ b/test/LiquidStakingProxy/LiquidStakingProxy.withdrawFromAccount.test.ts @@ -0,0 +1,103 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { TacLocalTestSdk } from "@tonappchain/evm-ccl"; +import { TacSAFactory__factory } from "@tonappchain/evm-ccl/dist/typechain-types"; +import { + deployLiquidStakingProxy, + LiquidStakingDependencies, + ONE_BN, + ZERO_BN, +} from "./LiquidStakingProxy.fixture.test"; +import { LiquidStakingProxy } from "../../typechain-types"; +import { sendMessage } from "./LiquidStakingProxy.helpers.test"; + +describe("Method: withdrawFromAccount: ", () => { + const TVM_CALLER = "TVM-USER-1"; + + let ownerAccount: SignerWithAddress; + + let testSdk: TacLocalTestSdk; + + before(async () => { + [ownerAccount] = await ethers.getSigners(); + + testSdk = new TacLocalTestSdk(); + }); + + async function deployFixture(): Promise< + { liquidStakingProxy: LiquidStakingProxy } & LiquidStakingDependencies + > { + return await deployLiquidStakingProxy(testSdk, ownerAccount); + } + + describe("When one of parameters is incorrect", () => { + describe("When the caller is not a cross chain contract", () => { + it("should revert with OnlyCrossChainLayer", async () => { + const { liquidStakingProxy } = await loadFixture(deployFixture); + + await expect( + liquidStakingProxy + .connect(ownerAccount) + .withdrawFromAccount("0x", "0x") + ) + .to.be.revertedWithCustomError( + liquidStakingProxy, + "OnlyCrossChainLayer" + ) + .withArgs(); + }); + }); + + describe("When no balance to withdraw", () => { + it("should revert with NoBalanceToWithdraw", async () => { + const { liquidStakingProxy } = await loadFixture(deployFixture); + + await expect( + sendMessage( + testSdk, + TVM_CALLER, + liquidStakingProxy.target as string, + "withdrawFromAccount(bytes,bytes)", + "0x", + ZERO_BN + ) + ).to.be.rejectedWith(/ProxyCallError: custom error 0xbbd81708/); // NoBalanceToWithdraw() + }); + }); + }); + + describe("When all parameters correct", () => { + let liquidStakingProxy: LiquidStakingProxy; + + before(async () => { + const snapshot = await loadFixture(deployFixture); + liquidStakingProxy = snapshot.liquidStakingProxy; + const smartAccountFactory = TacSAFactory__factory.connect( + snapshot.saFactoryAddress, + ownerAccount + ); + const smartAccountAddress = + await smartAccountFactory.getSmartAccountForApplication( + TVM_CALLER, + liquidStakingProxy.target + ); + await ownerAccount.sendTransaction({ + to: smartAccountAddress, + value: ONE_BN, + }); + }); + + it("should success", async () => { + await sendMessage( + testSdk, + TVM_CALLER, + liquidStakingProxy.target as string, + "withdrawFromAccount(bytes,bytes)", + "0x", + ZERO_BN + ); + }); + }); +}); diff --git a/test/LiquidStakingProxy/index.test.ts b/test/LiquidStakingProxy/index.test.ts new file mode 100644 index 0000000..884f950 --- /dev/null +++ b/test/LiquidStakingProxy/index.test.ts @@ -0,0 +1,6 @@ +describe("LiquidStakingProxy: ", () => { + require("./LiquidStakingProxy.initialize.test"); + require("./LiquidStakingProxy.liquidStake.test"); + require("./LiquidStakingProxy.liquidUnstake.test"); + require("./LiquidStakingProxy.withdrawFromAccount.test"); +});