From 399ee7ed69734887c9cbcd8214bf5c472629094e Mon Sep 17 00:00:00 2001 From: Volnov Petr <41264338+pvolnov@users.noreply.github.com> Date: Thu, 15 Dec 2022 05:33:25 -0500 Subject: [PATCH] Feat/staking (#6) * feat: staking storage support * feat: staking storage support * feat: staking storage support * feat: staking storage support * feat: docs --- .gitignore | 4 +- docs/clients/staking.rst | 169 +++++++++++++++++++++++ docs/index.rst | 5 +- pyproject.toml | 6 +- src/pynear/account.py | 63 ++++++--- src/pynear/dapps/core.py | 5 + src/pynear/dapps/keypom/__init__.py | 1 + src/pynear/dapps/keypom/async_client.py | 52 +++++++ src/pynear/dapps/keypom/exceptions.py | 2 + src/pynear/dapps/keypom/models.py | 102 ++++++++++++++ src/pynear/dapps/staking/__init__.py | 1 + src/pynear/dapps/staking/async_client.py | 167 ++++++++++++++++++++++ src/pynear/dapps/staking/exceptions.py | 6 + src/pynear/dapps/staking/models.py | 7 + src/pynear/models.py | 4 + src/pynear/providers.py | 2 +- 16 files changed, 570 insertions(+), 26 deletions(-) create mode 100644 docs/clients/staking.rst create mode 100644 src/pynear/dapps/keypom/__init__.py create mode 100644 src/pynear/dapps/keypom/async_client.py create mode 100644 src/pynear/dapps/keypom/exceptions.py create mode 100644 src/pynear/dapps/keypom/models.py create mode 100644 src/pynear/dapps/staking/__init__.py create mode 100644 src/pynear/dapps/staking/async_client.py create mode 100644 src/pynear/dapps/staking/exceptions.py create mode 100644 src/pynear/dapps/staking/models.py diff --git a/.gitignore b/.gitignore index 5211780..4265e89 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ venv -async_near.egg-info build dist .idea -tests \ No newline at end of file +tests +py_near.egg-info diff --git a/docs/clients/staking.rst b/docs/clients/staking.rst new file mode 100644 index 0000000..01f150a --- /dev/null +++ b/docs/clients/staking.rst @@ -0,0 +1,169 @@ + +Staking +====================== + +.. note:: + Class to stake NEAR on liquid staking. You can stake NEAR and withdraw at any time without fees + + Read more about staking: https://docs.herewallet.app/technology-description/readme + + +Quick start +----------- + +.. code:: python + + from pynear.account import Account + from pynear.dapps.fts import FTS + import asyncio + + ACCOUNT_ID = "mydev.near" + PRIVATE_KEY = "ed25519:..." + + + async def main(): + acc = Account(ACCOUNT_ID, PRIVATE_KEY) + await acc.startup() + transaction = await acc.staking.stake(10000) + print(tr.transaction.hash) + + transaction = await acc.staking.receive_dividends() + print(tr.logs) + + transaction = await acc.staking.unstake(10000) + print(tr.transaction.hash) + + asyncio.run(main()) + +Documentation +------------- + +.. class:: Staking(DappClient) + + Client to `storage.herewallet.near contract` + With this contract you can stake NEAR without blocking and receive passive income ~9% APY + + .. code:: python + + acc = Account(...) + staking = acc.staking + + +.. class:: StakingData(DappClient) + + .. py:attribute:: apy_value + :type: int + + current APY value * 100 (908=9.08%) + + .. py:attribute:: last_accrual_ts + :type: int + + Last UTC timestamp of accrued recalc + + .. py:attribute:: accrued + :type: int + + Total accrued in yoctoNEAR, which can be receiver by `receive_dividends()` call + + +.. function:: transfer(account_id: str, amount: int, memo: str = "", force_register: bool = False) + + Transfer hNEAR to account + + :param receiver_id: receiver account id + :param amount: amount in yoctoNEAR + :param memo: comment + :param nowait if True, method will return before transaction is confirmed + :return: transaction hash ot TransactionResult + + .. code:: python + + await acc.staking.transfer("azbang.near", 10000) + + +.. function:: transfer_call(account_id: str, amount: int, memo: str = "", force_register: bool = False) + + Transfer hNEAR to account and call on_transfer_call() on receiver smart contract + + :param receiver_id: receiver account id + :param amount: amount in yoctoNEAR + :param memo: comment + :param nowait if True, method will return before transaction is confirmed + :return: transaction hash ot TransactionResult + + .. code:: python + + await acc.staking.transfer_call("azbang.near", 10000) + + + +.. function:: get_staking_amount(account_id: str) + + Get staking balance of account. + + :param account_id: account id + :param nowait if True, method will return before transaction is confirmed + :return: int balance in yoctoNEAR + + .. code:: python + + amount = await acc.staking.get_staking_amount("azbang.near") + print(amount) + + +.. function:: get_user(account_id: str) + + Get user staking parameters + + :param account_id: account id + :return: StakingData + + .. code:: python + + data = await acc.staking.get_user("azbang.near") + print(data.apy_value / 100) + + + +.. function:: stake(amount: int, nowait: bool = False) + + Deposit staking for account + + :param amount: in amount of yoctoNEAR + :param nowait: if True, method will return before transaction is confirmed + :return: transaction hash or TransactionResult + + .. code:: python + + res = await acc.staking.stake(1_000_000_000_000_000) + print(res.transaction.hash) + + + +.. function:: unstake(amount: int, nowait: bool = False) + + Withdraw from staking + + :param amount: in amount of yoctoNEAR + :param nowait: if True, method will return before transaction is confirmed + :return: transaction hash or TransactionResult + + .. code:: python + + res = await acc.staking.unstake(1_000_000_000_000_000) + print(res.transaction.hash) + + +.. function:: receive_dividends(nowait: bool = False) + + Receive dividends. user.accrued yoctoNEAR amount will transfer to staking balance + + :param nowait: if True, method will return before transaction is confirmed + :return: transaction hash ot TransactionResult + + .. code:: python + + res = await acc.staking.receive_dividends() + print(res.transaction.hash) + diff --git a/docs/index.rst b/docs/index.rst index b78bbf4..929af26 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,15 +4,18 @@ Welcome to py-near's documentation! .. toctree:: :glob: - :titlesonly: + :maxdepth: 2 quickstart.rst account.rst + clients/phone.rst clients/fungible-token.rst + clients/staking.rst .. include:: quickstart.rst .. include:: account.rst .. include:: clients/phone.rst .. include:: clients/fungible-token.rst +.. include:: clients/staking.rst diff --git a/pyproject.toml b/pyproject.toml index a517193..6e34566 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,9 @@ [tool.poetry] name = "py-near" -version = "0.1.4" +version = "1.0.5" description="Pretty simple and fully asynchronous framework for working with NEAR blockchain" authors = ["pvolnov "] +readme = "README.md" [tool.poetry.dependencies] python = "^3.7" @@ -21,7 +22,7 @@ base58 = "^2.1.1" [project] name = "py-near" -version = "0.1.4" +version = "1.0.5" description = "Pretty simple and fully asynchronous framework for working with NEAR blockchaink" authors = [ {name = "pvolnov", email = "petr@herewallet.app"} ] requires-python = ">=3.7" @@ -33,6 +34,7 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] +readme = "README.md" [tool.setuptools.packages.find] namespaces = true diff --git a/src/pynear/account.py b/src/pynear/account.py index 43e5da8..ea7cfd1 100644 --- a/src/pynear/account.py +++ b/src/pynear/account.py @@ -11,6 +11,7 @@ from pynear import constants from pynear.dapps.ft.async_client import FT from pynear.dapps.phone.async_client import Phone +from pynear.dapps.staking.async_client import Staking from pynear.exceptions.exceptions import ( AccountAlreadyExistsError, AccountDoesNotExistError, @@ -64,24 +65,29 @@ class Account(object): """ _access_key: dict - _lock: asyncio.Lock + _lock: asyncio.Lock = None _latest_block_hash: str _latest_block_hash_ts: float = 0 chain_id: str = "mainnet" def __init__( - self, account_id, private_key, rpc_addr=constants.RPC_MAINNET + self, + account_id: str = None, + private_key: str = None, + rpc_addr="https://rpc.mainnet.near.org", ): - if isinstance(private_key, str): - private_key = base58.b58decode(private_key.replace("ed25519:", "")) self._provider = JsonProvider(rpc_addr) + if private_key is None: + return + if isinstance(private_key, str): + private_key = base58.b58decode(private_key.replace("ed25519:", "")) + key = ED25519SecretKey(private_key) self._signer = InMemorySigner( AccountId(account_id), - ED25519SecretKey(private_key).public_key(), - ED25519SecretKey(private_key), + key.public_key(), + key, ) - self._account_id = account_id async def startup(self): """ @@ -114,6 +120,10 @@ async def _sign_and_submit_tx( confirm and return TransactionResult :return: transaction hash or TransactionResult """ + if self._signer is None: + raise ValueError("You must provide a private key or seed to call methods") + if self._lock is None: + await self.startup() async with self._lock: access_key = await self.get_access_key() await self._update_last_block_hash() @@ -139,8 +149,8 @@ async def _sign_and_submit_tx( return TransactionResult(**result) @property - def account_id(self) -> AccountId: - return self._account_id + def account_id(self) -> str: + return str(self._signer.account_id) @property def signer(self) -> InMemorySigner: @@ -155,11 +165,16 @@ async def get_access_key(self) -> AccountAccessKey: Get access key for current account :return: AccountAccessKey """ - return AccountAccessKey( - **await self._provider.get_access_key( - self._account_id, str(self._signer.public_key) + if self._signer is None: + raise ValueError( + "Signer is not initialized, use Account(account_id, private_key)" ) + resp = await self._provider.get_access_key( + self.account_id, str(self._signer.public_key) ) + if "error" in resp: + raise ValueError(resp["error"]) + return AccountAccessKey(**resp) async def get_access_key_list(self, account_id: str = None) -> List[PublicKey]: """ @@ -168,7 +183,7 @@ async def get_access_key_list(self, account_id: str = None) -> List[PublicKey]: :return: list of PublicKey """ if account_id is None: - account_id = self._account_id + account_id = self.account_id resp = await self._provider.get_access_key_list(account_id) result = [] if "keys" in resp and isinstance(resp["keys"], list): @@ -178,7 +193,7 @@ async def get_access_key_list(self, account_id: str = None) -> List[PublicKey]: async def fetch_state(self) -> dict: """Fetch state for given account.""" - return await self._provider.get_account(self._account_id) + return await self._provider.get_account(self.account_id) async def send_money(self, account_id: str, amount: int, nowait: bool = False) -> TransactionResult: """ @@ -265,7 +280,7 @@ async def add_public_key( public_key, allowance, receiver_id, method_names ), ] - return await self._sign_and_submit_tx(self._account_id, actions, nowait) + return await self._sign_and_submit_tx(self.account_id, actions, nowait) async def add_full_access_public_key( self, public_key: Union[str, bytes], nowait=False @@ -279,7 +294,7 @@ async def add_full_access_public_key( actions = [ transactions.create_full_access_key_action(public_key), ] - return await self._sign_and_submit_tx(self._account_id, actions, nowait) + return await self._sign_and_submit_tx(self.account_id, actions, nowait) async def delete_public_key(self, public_key: Union[str, bytes], nowait=False): """ @@ -291,7 +306,7 @@ async def delete_public_key(self, public_key: Union[str, bytes], nowait=False): actions = [ transactions.create_delete_access_key_action(public_key), ] - return await self._sign_and_submit_tx(self._account_id, actions, nowait) + return await self._sign_and_submit_tx(self.account_id, actions, nowait) async def deploy_contract(self, contract_code: bytes, nowait=False): """ @@ -301,7 +316,7 @@ async def deploy_contract(self, contract_code: bytes, nowait=False): :return: transaction hash or TransactionResult """ return await self._sign_and_submit_tx( - self._account_id, + self.account_id, [transactions.create_deploy_contract_action(contract_code)], nowait, ) @@ -315,7 +330,7 @@ async def stake(self, public_key: str, amount: str, nowait=False): :return: transaction hash or TransactionResult """ return await self._sign_and_submit_tx( - self._account_id, + self.account_id, [transactions.create_staking_action(public_key, amount)], nowait, ) @@ -345,7 +360,7 @@ async def get_balance(self, account_id: str = None) -> int: :return: balance of account in yoctoNEAR """ if account_id is None: - account_id = self._account_id + account_id = self.account_id return int((await self._provider.get_account(account_id))["amount"]) @property @@ -363,3 +378,11 @@ def ft(self): :return: FT(self) """ return FT(self) + + @property + def staking(self): + """ + Get client for staking + :return: Staking(self) + """ + return Staking(self) diff --git a/src/pynear/dapps/core.py b/src/pynear/dapps/core.py index 5eb941a..bd15e58 100644 --- a/src/pynear/dapps/core.py +++ b/src/pynear/dapps/core.py @@ -1,5 +1,10 @@ NEAR = 1_000_000_000_000_000_000_000_000 +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pynear.account import Account + class DappClient: def __init__(self, account: "Account"): diff --git a/src/pynear/dapps/keypom/__init__.py b/src/pynear/dapps/keypom/__init__.py new file mode 100644 index 0000000..a5d28da --- /dev/null +++ b/src/pynear/dapps/keypom/__init__.py @@ -0,0 +1 @@ +from .async_client import Phone diff --git a/src/pynear/dapps/keypom/async_client.py b/src/pynear/dapps/keypom/async_client.py new file mode 100644 index 0000000..b9d85a3 --- /dev/null +++ b/src/pynear/dapps/keypom/async_client.py @@ -0,0 +1,52 @@ +from pynear.dapps.core import DappClient +from pynear.dapps.keypom.models import CreateDropModel + +_KEYPOM_CONTRACT_ID = "keypom.near" + + +class KeyPom(DappClient): + """ + Client to keypom.near contract + With this contract you can send NEAR and fungible tokens to linkdrop and receive FT/NFT/NEAR from other linkdrops + """ + + def __init__(self, account, contract_id=_KEYPOM_CONTRACT_ID): + """ + + :param account: + :param contract_id: keypom contract id + :param network: "mainnet" or "testnet" + """ + if account.chain_id != "mainnet": + raise ValueError("Only mainnet is supported") + super().__init__(account) + self.contract_id = contract_id + + def create_drop( + self, + drop: CreateDropModel, + ) -> str: + """ + + :param drop: CreateDropModel + :return: drop id + """ + res = await self._account.view_function( + self.contract_id, + "create_drop", + drop.dict(), + ) + return res.result + + async def claim(self, account_id: str, password: str): + """ + + :param account_id: linkdrop receiver account id + :param password: linkdrop password + :return: + """ + return await self._account.view_function( + self.contract_id, + "claim", + {"account_id": account_id, password: password}, + ) diff --git a/src/pynear/dapps/keypom/exceptions.py b/src/pynear/dapps/keypom/exceptions.py new file mode 100644 index 0000000..f4ee952 --- /dev/null +++ b/src/pynear/dapps/keypom/exceptions.py @@ -0,0 +1,2 @@ +class RequestLimitError(Exception): + pass diff --git a/src/pynear/dapps/keypom/models.py b/src/pynear/dapps/keypom/models.py new file mode 100644 index 0000000..69fb2b7 --- /dev/null +++ b/src/pynear/dapps/keypom/models.py @@ -0,0 +1,102 @@ +from enum import Enum +from typing import Optional, Dict, List + +from pydantic import BaseModel + + +class DropPermissionEnum(str, Enum): + CLAIM = "claim" + CREATE_ACCOUNT_AND_CLAIM = "create_account_and_claim" + + +class DropKeyConfig(BaseModel): + remaining_uses: int + last_used: int + allowance: int + key_id: int + pw_per_use: Optional[Dict[int, bytes]] + pw_per_key: Optional[bytes] + + +class DropTimeConfig(BaseModel): + start: Optional[int] + end: Optional[int] + throttle: Optional[int] + interval: Optional[int] + + +class DropUsageConfig(BaseModel): + permissions: Optional[DropPermissionEnum] + refund_deposit: Optional[bool] + auto_delete_drop: Optional[bool] + auto_withdraw: Optional[bool] + + +class DropConfig(BaseModel): + uses_per_key: Optional[int] + time: Optional[DropTimeConfig] + usage: Optional[DropUsageConfig] + root_account_id: Optional[str] + + +class SimpleData(BaseModel): + lazy_register: Optional[bool] + + +class JsonFTData(BaseModel): + contract_id: str + sender_id: str + balance_per_use: int + + +class JsonNFTData(BaseModel): + sender_id: str + contract_id: str + + +class FCConfig(BaseModel): + attached_gas: Optional[int] + + +class MethodData(BaseModel): + receiver_id: str + method_name: str + args: str + attached_deposit: int + account_id_field: Optional[str] + drop_id_field: Optional[str] + key_id_field: Optional[str] + + +class FCData(BaseModel): + methods: List[Optional[List[MethodData]]] + config: Optional[FCConfig] + + +class JsonKeyInfo(BaseModel): + drop_id: str + pk: str + cur_key_use: int + remaining_uses: int + last_used: int + allowance: int + key_id: int + + +class JsonPasswordForUse(BaseModel): + pw: str + key_use: int + + +class CreateDropModel(BaseModel): + public_keys: Optional[List[str]] + deposit_per_use: int + drop_id: Optional[str] + config: Optional[DropConfig] + metadata: Optional[str] + simple: Optional[SimpleData] + ft: Optional[JsonFTData] + nft: Optional[JsonNFTData] + fc: Optional[FCData] + passwords_per_use: Optional[List[Optional[List[JsonPasswordForUse]]]] + passwords_per_key: Optional[List[Optional[str]]] diff --git a/src/pynear/dapps/staking/__init__.py b/src/pynear/dapps/staking/__init__.py new file mode 100644 index 0000000..5a0aaed --- /dev/null +++ b/src/pynear/dapps/staking/__init__.py @@ -0,0 +1 @@ +from .async_client import Staking diff --git a/src/pynear/dapps/staking/async_client.py b/src/pynear/dapps/staking/async_client.py new file mode 100644 index 0000000..21e4520 --- /dev/null +++ b/src/pynear/dapps/staking/async_client.py @@ -0,0 +1,167 @@ +from typing import Optional + +from pynear.dapps.core import DappClient +from pynear.dapps.staking.exceptions import NotEnoughBalance +from pynear.dapps.staking.models import StakingData +from pynear.exceptions.exceptions import FunctionCallError + +CONTRACT_ID = { + "mainnet": "storage.herewallet.near", + "testnet": "storage.herewallet.testnet", +} + + +class Staking(DappClient): + async def transfer( + self, + receiver_id: str, + amount: float, + memo: str = "", + nowait: bool = False, + ): + """ + Transfer hNEAR to account + + :param receiver_id: receiver account id + :param amount: amount in yoctoNEAR + :param memo: comment + :param nowait if True, method will return before transaction is confirmed + :return: transaction hash ot TransactionResult + """ + + try: + return await self._account.function_call( + CONTRACT_ID[self._account.chain_id], + "ft_transfer", + { + "receiver_id": receiver_id, + "amount": str(amount), + "msg": memo, + }, + amount=1, + nowait=nowait, + ) + except FunctionCallError as e: + if "The account doesn't have enough balance" in e.error["ExecutionError"]: + raise NotEnoughBalance(e) + raise e + + async def transfer_call( + self, + receiver_id: str, + amount: int, + memo: str = "", + nowait: bool = False, + ): + """ + Transfer hNEAR to account and call ft_on_transfer() method in receiver contract + + :param receiver_id: receiver account id + :param amount: amount in yoctoNEAR + :param memo: comment + :param nowait if True, method will return before transaction is confirmed + :return: transaction hash ot TransactionResult + """ + return await self._account.function_call( + CONTRACT_ID[self._account.chain_id], + "ft_transfer_call", + { + "receiver_id": receiver_id, + "amount": str(amount), + "msg": memo, + }, + amount=1, + nowait=nowait, + ) + + async def get_staking_amount(self, account_id: str = None) -> int: + """ + Get staking balance of account. + + :param account_id: account id + :param nowait if True, method will return before transaction is confirmed + :return: int balance in yoctoNEAR + """ + if account_id is None: + account_id = self._account.account_id + res = ( + await self._account.view_function( + CONTRACT_ID[self._account.chain_id], + "ft_balance_of", + {"account_id": account_id}, + ) + ).result + if res: + return int(res) + return 0 + + async def get_user(self, account_id: str = None) -> Optional[StakingData]: + """ + Get user staking parameters + + :param account_id: account id + :return: StakingData + """ + if account_id is None: + account_id = self._account.account_id + res = ( + await self._account.view_function( + CONTRACT_ID[self._account.chain_id], + "get_user", + {"account_id": account_id}, + ) + ).result + if res: + return StakingData(**res) + + async def stake(self, amount: int, nowait: bool = False): + """ + Deposit staking for account + + :param amount: in amount of yoctoNEAR + :param nowait: if True, method will return before transaction is confirmed + :return: transaction hash or TransactionResult + """ + return await self._account.function_call( + CONTRACT_ID[self._account.chain_id], + "storage_deposit", + {}, + amount=amount, + nowait=nowait, + ) + + async def unstake(self, amount: int, nowait: bool = False): + """ + Withdraw from staking + + :param amount: in amount of yoctoNEAR + :param nowait: if True, method will return before transaction is confirmed + :return: transaction hash or TransactionResult + """ + try: + return await self._account.function_call( + CONTRACT_ID[self._account.chain_id], + "storage_withdraw", + {"amount": str(int(amount))}, + amount=1, + nowait=nowait, + ) + except FunctionCallError as e: + if "The account doesn't have enough balance" in e: + raise NotEnoughBalance + raise e + + async def receive_dividends(self, nowait=False): + """ + Receive dividends + + :param nowait: if True, method will return before transaction is confirmed + :return: transaction hash ot TransactionResult + """ + return await self._account.function_call( + CONTRACT_ID[self._account.chain_id], + "receive_dividends", + {}, + amount=1, + nowait=nowait, + ) diff --git a/src/pynear/dapps/staking/exceptions.py b/src/pynear/dapps/staking/exceptions.py new file mode 100644 index 0000000..e2530e2 --- /dev/null +++ b/src/pynear/dapps/staking/exceptions.py @@ -0,0 +1,6 @@ +class NotEnoughBalance(Exception): + pass + + +class NotRegisteredError(Exception): + pass diff --git a/src/pynear/dapps/staking/models.py b/src/pynear/dapps/staking/models.py new file mode 100644 index 0000000..5fb40c0 --- /dev/null +++ b/src/pynear/dapps/staking/models.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class StakingData(BaseModel): + apy_value: int + last_accrual_ts: int + accrued: int diff --git a/src/pynear/models.py b/src/pynear/models.py index 9e30672..0e7411c 100644 --- a/src/pynear/models.py +++ b/src/pynear/models.py @@ -49,6 +49,10 @@ def __init__( self.signature = signature self.hash = hash + @property + def url(self): + return f"https://explorer.near.org/transactions/{self.hash}" + class TransactionResult: receipt_outcome: List[ReceiptOutcome] diff --git a/src/pynear/providers.py b/src/pynear/providers.py index 1419cf8..80c6b30 100644 --- a/src/pynear/providers.py +++ b/src/pynear/providers.py @@ -112,7 +112,7 @@ async def send_tx_and_wait(self, signed_tx: str, timeout: int = constants.TIMEOU async def get_status(self): async with aiohttp.ClientSession() as session: - r = await session.get("%s/status" % self._rpc_addresses[0], timeout=5) + r = await session.get("%s/status" % self._rpc_addresses[0], timeout=60) r.raise_for_status() return json.loads(await r.text())