diff --git a/.gitignore b/.gitignore index bd2eb18e5004e410231c59a0f5e0cb4c52591ae2..c07bf5af7eccadcefa2449603dc75cd6b9dd8577 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,5 @@ target/ # Vim temp files *.swp +.ropeproject/ +*/.ropeproject/ diff --git a/.travis.yml b/.travis.yml index a0164f0624344c5094e0ee46358edc9a9bbe89c7..2d9438abff68c18e630d0ec2358300fe1d032b11 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,9 @@ language: python -python: "3.5" +python: + - 3.5 install: - pip install tox codecov script: - tox -env: - - TOXENV=py35 after_success: - codecov diff --git a/bitshares/account.py b/bitshares/account.py index 25efca5a740d74da2a3b630fd7bf2ba286009c69..a646f7d105afe3e2b3004debfce15769d9704e45 100644 --- a/bitshares/account.py +++ b/bitshares/account.py @@ -165,7 +165,7 @@ class Account(BlockchainObject): api="history" ) if not mostrecent: - raise StopIteration + return if not first: # first = int(mostrecent[0].get("id").split(".")[2]) + 1 @@ -191,7 +191,7 @@ class Account(BlockchainObject): cnt += 1 yield i if limit >= 0 and cnt >= limit: - raise StopIteration + return if not txs: break diff --git a/bitshares/asset.py b/bitshares/asset.py index 9682d71d7de608736b6205032e4977fef712c46c..a4c83dd5fffd7691e98ee68d8c2da2049dbb10db 100644 --- a/bitshares/asset.py +++ b/bitshares/asset.py @@ -48,7 +48,7 @@ class Asset(BlockchainObject): """ asset = self.bitshares.rpc.get_asset(self.identifier) if not asset: - raise AssetDoesNotExistsException + raise AssetDoesNotExistsException(self.identifier) super(Asset, self).__init__(asset) if self.full: if "bitasset_data_id" in asset: diff --git a/bitshares/bitshares.py b/bitshares/bitshares.py index f974c5b8c3406f6e550e7cf9136635a58f1f3a7c..a87eb593bae688f6b89eab20ff0bcaf26f864033 100644 --- a/bitshares/bitshares.py +++ b/bitshares/bitshares.py @@ -24,7 +24,8 @@ from .exceptions import ( MissingKeyError, ) from .wallet import Wallet -from .transactionbuilder import TransactionBuilder +from .transactionbuilder import TransactionBuilder, ProposalBuilder +from .utils import formatTime, test_proposal_in_buffer log = logging.getLogger(__name__) @@ -35,16 +36,26 @@ class BitShares(object): :param str node: Node to connect to *(optional)* :param str rpcuser: RPC user *(optional)* :param str rpcpassword: RPC password *(optional)* - :param bool nobroadcast: Do **not** broadcast a transaction! *(optional)* + :param bool nobroadcast: Do **not** broadcast a transaction! + *(optional)* :param bool debug: Enable Debugging *(optional)* - :param array,dict,string keys: Predefine the wif keys to shortcut the wallet database *(optional)* - :param bool offline: Boolean to prevent connecting to network (defaults to ``False``) *(optional)* - :param str proposer: Propose a transaction using this proposer *(optional)* - :param int proposal_expiration: Expiration time (in seconds) for the proposal *(optional)* - :param int proposal_review: Review period (in seconds) for the proposal *(optional)* - :param int expiration: Delay in seconds until transactions are supposed to expire *(optional)* - :param str blocking: Wait for broadcasted transactions to be included in a block and return full transaction (can be "head" or "irrversible") - :param bool bundle: Do not broadcast transactions right away, but allow to bundle operations *(optional)* + :param array,dict,string keys: Predefine the wif keys to shortcut the + wallet database *(optional)* + :param bool offline: Boolean to prevent connecting to network (defaults + to ``False``) *(optional)* + :param str proposer: Propose a transaction using this proposer + *(optional)* + :param int proposal_expiration: Expiration time (in seconds) for the + proposal *(optional)* + :param int proposal_review: Review period (in seconds) for the proposal + *(optional)* + :param int expiration: Delay in seconds until transactions are supposed + to expire *(optional)* + :param str blocking: Wait for broadcasted transactions to be included + in a block and return full transaction (can be "head" or + "irrversible") + :param bool bundle: Do not broadcast transactions right away, but allow + to bundle operations *(optional)* Three wallet operation modes are possible: @@ -64,8 +75,8 @@ class BitShares(object): signatures! If no node is provided, it will connect to the node of - http://uptick.rocks. It is **highly** recommended that you pick your own - node instead. Default settings can be changed with: + http://uptick.rocks. It is **highly** recommended that you + pick your own node instead. Default settings can be changed with: .. code-block:: python @@ -84,7 +95,8 @@ class BitShares(object): bitshares = BitShares() print(bitshares.info()) - All that is requires is for the user to have added a key with uptick + All that is requires is for the user to have added a key with + ``uptick`` .. code-block:: bash @@ -120,14 +132,14 @@ class BitShares(object): self.nobroadcast = bool(kwargs.get("nobroadcast", False)) self.unsigned = bool(kwargs.get("unsigned", False)) self.expiration = int(kwargs.get("expiration", 30)) - self.proposer = kwargs.get("proposer", None) - self.proposal_expiration = int(kwargs.get("proposal_expiration", 60 * 60 * 24)) - self.proposal_review = int(kwargs.get("proposal_review", 0)) self.bundle = bool(kwargs.get("bundle", False)) self.blocking = kwargs.get("blocking", False) - # Multiple txbuffers can be stored here - self._txbuffers = [] + # Legacy Proposal attributes + self.proposer = kwargs.get("proposer", None) + self.proposal_expiration = int( + kwargs.get("proposal_expiration", 60 * 60 * 24)) + self.proposal_review = int(kwargs.get("proposal_review", 0)) # Store config for access through other Classes self.config = config @@ -139,7 +151,9 @@ class BitShares(object): **kwargs) self.wallet = Wallet(self.rpc, **kwargs) - self.new_txbuffer() + + # txbuffers/propbuffer are initialized and cleared + self.clear() # ------------------------------------------------------------------------- # Basic Calls @@ -170,20 +184,35 @@ class BitShares(object): :func:`bitshares.wallet.create`. :param str pwd: Password to use for the new wallet - :raises bitshares.exceptions.WalletExists: if there is already a wallet created + :raises bitshares.exceptions.WalletExists: if there is already a + wallet created """ self.wallet.create(pwd) - def finalizeOp(self, ops, account, permission): + def set_default_account(self, account): + """ Set the default account to be used + """ + Account(account) + config["default_account"] = account + + def finalizeOp(self, ops, account, permission, **kwargs): """ This method obtains the required private keys if present in the wallet, finalizes the transaction, signs it and broadacasts it - :param operation ops: The operation (or list of operaions) to broadcast + :param operation ops: The operation (or list of operaions) to + broadcast :param operation account: The account that authorizes the operation :param string permission: The required permission for signing (active, owner, posting) + :param object append_to: This allows to provide an instance of + ProposalsBuilder (see :func:`bitshares.new_proposal`) or + TransactionBuilder (see :func:`bitshares.new_tx()`) to specify + where to put a specific operation. + + ... note:: ``append_to`` is exposed to every method used in the + BitShares class ... note:: @@ -197,9 +226,34 @@ class BitShares(object): :class:`bitshares.transactionbuilder.TransactionBuilder`. You may want to use your own txbuffer """ - # Append transaction - self.txbuffer.appendOps(ops) + if "append_to" in kwargs and kwargs["append_to"]: + if self.proposer: + log.warn( + "You may not use append_to and bitshares.proposer at " + "the same time. Append bitshares.new_proposal(..) instead" + ) + # Append to the append_to and return + append_to = kwargs["append_to"] + parent = append_to.get_parent() + assert isinstance(append_to, (TransactionBuilder, ProposalBuilder)) + append_to.appendOps(ops) + # Add the signer to the buffer so we sign the tx properly + parent.appendSigner(account, permission) + # This returns as we used append_to, it does NOT broadcast, or sign + return append_to.get_parent() + elif self.proposer: + # Legacy proposer mode! + proposal = self.proposal() + proposal.set_proposer(self.proposer) + proposal.set_expiration(self.proposal_expiration) + proposal.set_review(self.proposal_review) + proposal.appendOps(ops) + # Go forward to see what the other options do ... + else: + # Append tot he default buffer + self.txbuffer.appendOps(ops) + # Add signing information, signer, sign and optionally broadcast if self.unsigned: # In case we don't want to sign anything self.txbuffer.addSigningInformation(account, permission) @@ -255,35 +309,144 @@ class BitShares(object): def txbuffer(self): """ Returns the currently active tx buffer """ - return self._txbuffers[self._current_txbuffer] + return self.tx() - def set_txbuffer(self, i): - """ Lets you switch the current txbuffer + @property + def propbuffer(self): + """ Return the default proposal buffer + """ + return self.proposal() - :param int i: Id of the txbuffer + def tx(self): + """ Returns the default transaction buffer """ - self._current_txbuffer = i + return self._txbuffers[0] - def get_txbuffer(self, i): - """ Returns the txbuffer with id i + def proposal( + self, + proposer=None, + proposal_expiration=None, + proposal_review=None + ): + """ Return the default proposal buffer + + ... note:: If any parameter is set, the default proposal + parameters will be changed! """ - if i < len(self._txbuffers): - return self._txbuffers[i] + if not self._propbuffer: + return self.new_proposal( + self.tx(), + proposer, + proposal_expiration, + proposal_review + ) + if proposer: + self._propbuffer[0].set_proposer(proposer) + if proposal_expiration: + self._propbuffer[0].set_expiration(proposal_expiration) + if proposal_review: + self._propbuffer[0].set_review(proposal_review) + return self._propbuffer[0] + + def new_proposal( + self, + parent=None, + proposer=None, + proposal_expiration=None, + proposal_review=None + ): + if not parent: + parent = self.tx() + if not proposal_expiration: + proposal_expiration = self.proposal_expiration + + if not proposal_review: + proposal_review = self.proposal_review - def new_txbuffer(self, *args, **kwargs): + if not proposer: + if "default_account" in config: + proposer = config["default_account"] + + # Else, we create a new object + proposal = ProposalBuilder( + proposer, + proposal_expiration, + proposal_review, + bitshares_instance=self, + parent=parent + ) + if parent: + parent.appendOps(proposal) + self._propbuffer.append(proposal) + return proposal + + def new_tx(self, *args, **kwargs): """ Let's obtain a new txbuffer :returns int txid: id of the new txbuffer """ - self._txbuffers.append(TransactionBuilder( + builder = TransactionBuilder( *args, bitshares_instance=self, **kwargs - )) - id = len(self._txbuffers) - 1 - self.set_txbuffer(id) - return id + ) + self._txbuffers.append(builder) + return builder + + def clear(self): + self._txbuffers = [] + self._propbuffer = [] + # Base/Default proposal/tx buffers + self.new_tx() + # self.new_proposal() + + # ------------------------------------------------------------------------- + # Simple Transfer + # ------------------------------------------------------------------------- + def transfer(self, to, amount, asset, memo="", account=None, **kwargs): + """ Transfer an asset to another account. + + :param str to: Recipient + :param float amount: Amount to transfer + :param str asset: Asset to transfer + :param str memo: (optional) Memo, may begin with `#` for encrypted + messaging + :param str account: (optional) the source account for the transfer + if not ``default_account`` + """ + from .memo import Memo + if not account: + if "default_account" in config: + account = config["default_account"] + if not account: + raise ValueError("You need to provide an account") + + account = Account(account, bitshares_instance=self) + amount = Amount(amount, asset, bitshares_instance=self) + to = Account(to, bitshares_instance=self) + + memoObj = Memo( + from_account=account, + to_account=to, + bitshares_instance=self + ) + + op = operations.Transfer(**{ + "fee": {"amount": 0, "asset_id": "1.3.0"}, + "from": account["id"], + "to": to["id"], + "amount": { + "amount": int(amount), + "asset_id": amount.asset["id"] + }, + "memo": memoObj.encrypt(memo), + "prefix": self.rpc.chain_params["prefix"] + }) + return self.finalizeOp(op, account, "active", **kwargs) + # ------------------------------------------------------------------------- + # Account related calls + # ------------------------------------------------------------------------- def create_account( self, account_name, @@ -300,11 +463,12 @@ class BitShares(object): additional_active_accounts=[], proxy_account="proxy-to-self", storekeys=True, + **kwargs ): """ Create new account on BitShares - The brainkey/password can be used to recover all generated keys (see - `bitsharesbase.account` for more details. + The brainkey/password can be used to recover all generated keys + (see `bitsharesbase.account` for more details. By default, this call will use ``default_account`` to register a new name ``account_name`` with all keys being @@ -335,10 +499,14 @@ class BitShares(object): keys will be derived :param array additional_owner_keys: Additional owner public keys :param array additional_active_keys: Additional active public keys - :param array additional_owner_accounts: Additional owner account names - :param array additional_active_accounts: Additional acctive account names - :param bool storekeys: Store new keys in the wallet (default: ``True``) - :raises AccountExistsException: if the account already exists on the blockchain + :param array additional_owner_accounts: Additional owner account + names + :param array additional_active_accounts: Additional acctive account + names + :param bool storekeys: Store new keys in the wallet (default: + ``True``) + :raises AccountExistsException: if the account already exists on + the blockchain """ if not registrar and config["default_account"]: @@ -379,9 +547,12 @@ class BitShares(object): self.wallet.addPrivateKey(active_privkey) self.wallet.addPrivateKey(memo_privkey) elif (owner_key and active_key and memo_key): - active_pubkey = PublicKey(active_key, prefix=self.rpc.chain_params["prefix"]) - owner_pubkey = PublicKey(owner_key, prefix=self.rpc.chain_params["prefix"]) - memo_pubkey = PublicKey(memo_key, prefix=self.rpc.chain_params["prefix"]) + active_pubkey = PublicKey( + active_key, prefix=self.rpc.chain_params["prefix"]) + owner_pubkey = PublicKey( + owner_key, prefix=self.rpc.chain_params["prefix"]) + memo_pubkey = PublicKey( + memo_key, prefix=self.rpc.chain_params["prefix"]) else: raise ValueError( "Call incomplete! Provide either a password or public keys!" @@ -409,7 +580,8 @@ class BitShares(object): active_accounts_authority.append([addaccount["id"], 1]) # voting account - voting_account = Account(proxy_account or "proxy-to-self") + voting_account = Account( + proxy_account or "proxy-to-self", bitshares_instance=self) op = { "fee": {"amount": 0, "asset_id": "1.3.0"}, @@ -436,46 +608,27 @@ class BitShares(object): "prefix": self.rpc.chain_params["prefix"] } op = operations.Account_create(**op) - return self.finalizeOp(op, registrar, "active") + return self.finalizeOp(op, registrar, "active", **kwargs) - def transfer(self, to, amount, asset, memo="", account=None): - """ Transfer an asset to another account. + def upgrade_account(self, account=None, **kwargs): + """ Upgrade an account to Lifetime membership - :param str to: Recipient - :param float amount: Amount to transfer - :param str asset: Asset to transfer - :param str memo: (optional) Memo, may begin with `#` for encrypted messaging - :param str account: (optional) the source account for the transfer if not ``default_account`` + :param str account: (optional) the account to allow access + to (defaults to ``default_account``) """ - from .memo import Memo if not account: if "default_account" in config: account = config["default_account"] if not account: raise ValueError("You need to provide an account") - account = Account(account, bitshares_instance=self) - amount = Amount(amount, asset, bitshares_instance=self) - to = Account(to, bitshares_instance=self) - - memoObj = Memo( - from_account=account, - to_account=to, - bitshares_instance=self - ) - - op = operations.Transfer(**{ + op = operations.Account_upgrade(**{ "fee": {"amount": 0, "asset_id": "1.3.0"}, - "from": account["id"], - "to": to["id"], - "amount": { - "amount": int(amount), - "asset_id": amount.asset["id"] - }, - "memo": memoObj.encrypt(memo), + "account_to_upgrade": account["id"], + "upgrade_to_lifetime_member": True, "prefix": self.rpc.chain_params["prefix"] }) - return self.finalizeOp(op, account, "active") + return self.finalizeOp(op, account["name"], "active", **kwargs) def _test_weights_treshold(self, authority): """ This method raises an error if the threshold of an authority cannot @@ -486,14 +639,18 @@ class BitShares(object): """ weights = 0 for a in authority["account_auths"]: - weights += a[1] + weights += int(a[1]) for a in authority["key_auths"]: - weights += a[1] + weights += int(a[1]) if authority["weight_threshold"] > weights: raise ValueError("Threshold too restrictive!") + if authority["weight_threshold"] == 0: + raise ValueError("Cannot have threshold of 0") - def allow(self, foreign, weight=None, permission="active", - account=None, threshold=None): + def allow( + self, foreign, weight=None, permission="active", + account=None, threshold=None, **kwargs + ): """ Give additional access to an account by some other public key or account. @@ -555,12 +712,14 @@ class BitShares(object): "prefix": self.rpc.chain_params["prefix"] }) if permission == "owner": - return self.finalizeOp(op, account["name"], "owner") + return self.finalizeOp(op, account["name"], "owner", **kwargs) else: - return self.finalizeOp(op, account["name"], "active") + return self.finalizeOp(op, account["name"], "active", **kwargs) - def disallow(self, foreign, permission="active", - account=None, threshold=None): + def disallow( + self, foreign, permission="active", + account=None, threshold=None, **kwargs + ): """ Remove additional access to an account by some other public key or account. @@ -609,6 +768,8 @@ class BitShares(object): "Unknown foreign account or unvalid public key" ) + if not affected_items: + raise ValueError("Changes nothing!") removed_weight = affected_items[0][1] # Define threshold @@ -634,11 +795,11 @@ class BitShares(object): "extensions": {} }) if permission == "owner": - return self.finalizeOp(op, account["name"], "owner") + return self.finalizeOp(op, account["name"], "owner", **kwargs) else: - return self.finalizeOp(op, account["name"], "active") + return self.finalizeOp(op, account["name"], "active", **kwargs) - def update_memo_key(self, key, account=None): + def update_memo_key(self, key, account=None, **kwargs): """ Update an account's memo public key This method does **not** add any private keys to your @@ -664,12 +825,12 @@ class BitShares(object): "new_options": account["options"], "extensions": {} }) - return self.finalizeOp(op, account["name"], "active") + return self.finalizeOp(op, account["name"], "active", **kwargs) # ------------------------------------------------------------------------- # Approval and Disapproval of witnesses, workers, committee, and proposals # ------------------------------------------------------------------------- - def approvewitness(self, witnesses, account=None): + def approvewitness(self, witnesses, account=None, **kwargs): """ Approve a witness :param list witnesses: list of Witness name or id @@ -704,9 +865,9 @@ class BitShares(object): "extensions": {}, "prefix": self.rpc.chain_params["prefix"] }) - return self.finalizeOp(op, account["name"], "active") + return self.finalizeOp(op, account["name"], "active", **kwargs) - def disapprovewitness(self, witnesses, account=None): + def disapprovewitness(self, witnesses, account=None, **kwargs): """ Disapprove a witness :param list witnesses: list of Witness name or id @@ -742,9 +903,9 @@ class BitShares(object): "extensions": {}, "prefix": self.rpc.chain_params["prefix"] }) - return self.finalizeOp(op, account["name"], "active") + return self.finalizeOp(op, account["name"], "active", **kwargs) - def approvecommittee(self, committees, account=None): + def approvecommittee(self, committees, account=None, **kwargs): """ Approve a committee :param list committees: list of committee member name or id @@ -779,9 +940,9 @@ class BitShares(object): "extensions": {}, "prefix": self.rpc.chain_params["prefix"] }) - return self.finalizeOp(op, account["name"], "active") + return self.finalizeOp(op, account["name"], "active", **kwargs) - def disapprovecommittee(self, committees, account=None): + def disapprovecommittee(self, committees, account=None, **kwargs): """ Disapprove a committee :param list committees: list of committee name or id @@ -817,9 +978,95 @@ class BitShares(object): "extensions": {}, "prefix": self.rpc.chain_params["prefix"] }) - return self.finalizeOp(op, account["name"], "active") + return self.finalizeOp(op, account["name"], "active", **kwargs) - def approveworker(self, workers, account=None): + def approveproposal( + self, proposal_ids, account=None, approver=None, **kwargs + ): + """ Approve Proposal + + :param list proposal_id: Ids of the proposals + :param str account: (optional) the account to allow access + to (defaults to ``default_account``) + """ + from .proposal import Proposal + if not account: + if "default_account" in config: + account = config["default_account"] + if not account: + raise ValueError("You need to provide an account") + account = Account(account, bitshares_instance=self) + is_key = approver and approver[:3] == self.rpc.chain_params["prefix"] + if not approver and not is_key: + approver = account + elif approver and not is_key: + approver = Account(approver, bitshares_instance=self) + else: + approver = PublicKey(approver) + + if not isinstance(proposal_ids, (list, set, tuple)): + proposal_ids = {proposal_ids} + + op = [] + for proposal_id in proposal_ids: + proposal = Proposal(proposal_id, bitshares_instance=self) + update_dict = { + "fee": {"amount": 0, "asset_id": "1.3.0"}, + 'fee_paying_account': account["id"], + 'proposal': proposal["id"], + 'active_approvals_to_add': [approver["id"]], + "prefix": self.rpc.chain_params["prefix"] + } + if is_key: + update_dict.update({ + 'key_approvals_to_add': [str(approver)], + }) + else: + update_dict.update({ + 'active_approvals_to_add': [approver["id"]], + }) + op.append(operations.Proposal_update(**update_dict)) + if is_key: + self.txbuffer.appendSigner(account["name"], "active") + return self.finalizeOp(op, approver["name"], "active", **kwargs) + + def disapproveproposal( + self, proposal_ids, account=None, approver=None, **kwargs + ): + """ Disapprove Proposal + + :param list proposal_ids: Ids of the proposals + :param str account: (optional) the account to allow access + to (defaults to ``default_account``) + """ + from .proposal import Proposal + if not account: + if "default_account" in config: + account = config["default_account"] + if not account: + raise ValueError("You need to provide an account") + account = Account(account, bitshares_instance=self) + if not approver: + approver = account + else: + approver = Account(approver, bitshares_instance=self) + + if not isinstance(proposal_ids, (list, set, tuple)): + proposal_ids = {proposal_ids} + + op = [] + for proposal_id in proposal_ids: + proposal = Proposal(proposal_id, bitshares_instance=self) + op.append(operations.Proposal_update(**{ + "fee": {"amount": 0, "asset_id": "1.3.0"}, + 'fee_paying_account': account["id"], + 'proposal': proposal["id"], + 'active_approvals_to_remove': [approver["id"]], + "prefix": self.rpc.chain_params["prefix"] + })) + return self.finalizeOp(op, account["name"], "active", **kwargs) + + def approveworker(self, workers, account=None, **kwargs): """ Approve a worker :param list workers: list of worker member name or id @@ -849,9 +1096,9 @@ class BitShares(object): "extensions": {}, "prefix": self.rpc.chain_params["prefix"] }) - return self.finalizeOp(op, account["name"], "active") + return self.finalizeOp(op, account["name"], "active", **kwargs) - def disapproveworker(self, workers, account=None): + def disapproveworker(self, workers, account=None, **kwargs): """ Disapprove a worker :param list workers: list of worker name or id @@ -882,9 +1129,9 @@ class BitShares(object): "extensions": {}, "prefix": self.rpc.chain_params["prefix"] }) - return self.finalizeOp(op, account["name"], "active") + return self.finalizeOp(op, account["name"], "active", **kwargs) - def cancel(self, orderNumbers, account=None): + def cancel(self, orderNumbers, account=None, **kwargs): """ Cancels an order you have placed in a given market. Requires only the "orderNumbers". An order number takes the form ``1.7.xxx``. @@ -910,9 +1157,9 @@ class BitShares(object): "order": order, "extensions": [], "prefix": self.rpc.chain_params["prefix"]})) - return self.finalizeOp(op, account["name"], "active") + return self.finalizeOp(op, account["name"], "active", **kwargs) - def vesting_balance_withdraw(self, vesting_id, amount=None, account=None): + def vesting_balance_withdraw(self, vesting_id, amount=None, account=None, **kwargs): """ Withdraw vesting balance :param str vesting_id: Id of the vesting object @@ -944,89 +1191,6 @@ class BitShares(object): }) return self.finalizeOp(op, account["name"], "active") - def approveproposal(self, proposal_ids, account=None, approver=None): - """ Approve Proposal - - :param list proposal_id: Ids of the proposals - :param str account: (optional) the account to allow access - to (defaults to ``default_account``) - """ - from .proposal import Proposal - if not account: - if "default_account" in config: - account = config["default_account"] - if not account: - raise ValueError("You need to provide an account") - account = Account(account, bitshares_instance=self) - is_key = approver and approver[:3] == self.rpc.chain_params["prefix"] - if not approver and not is_key: - approver = account - elif approver and not is_key: - approver = Account(approver, bitshares_instance=self) - else: - approver = PublicKey(approver) - - if not isinstance(proposal_ids, (list, set, tuple)): - proposal_ids = {proposal_ids} - - op = [] - for proposal_id in proposal_ids: - proposal = Proposal(proposal_id, bitshares_instance=self) - update_dict = { - "fee": {"amount": 0, "asset_id": "1.3.0"}, - 'fee_paying_account': account["id"], - 'proposal': proposal["id"], - "prefix": self.rpc.chain_params["prefix"] - } - if is_key: - update_dict.update({ - 'key_approvals_to_add': [str(approver)], - }) - else: - update_dict.update({ - 'active_approvals_to_add': [approver["id"]], - }) - op.append(operations.Proposal_update(**update_dict)) - if is_key: - self.txbuffer.appendSigner(account["name"], "active") - return self.finalizeOp(op, approver, "active") - else: - return self.finalizeOp(op, approver["name"], "active") - - def disapproveproposal(self, proposal_ids, account=None, approver=None): - """ Disapprove Proposal - - :param list proposal_ids: Ids of the proposals - :param str account: (optional) the account to allow access - to (defaults to ``default_account``) - """ - from .proposal import Proposal - if not account: - if "default_account" in config: - account = config["default_account"] - if not account: - raise ValueError("You need to provide an account") - account = Account(account, bitshares_instance=self) - if not approver: - approver = account - else: - approver = Account(approver, bitshares_instance=self) - - if not isinstance(proposal_ids, (list, set, tuple)): - proposal_ids = {proposal_ids} - - op = [] - for proposal_id in proposal_ids: - proposal = Proposal(proposal_id, bitshares_instance=self) - op.append(operations.Proposal_update(**{ - "fee": {"amount": 0, "asset_id": "1.3.0"}, - 'fee_paying_account': account["id"], - 'proposal': proposal["id"], - 'active_approvals_to_remove': [approver["id"]], - "prefix": self.rpc.chain_params["prefix"] - })) - return self.finalizeOp(op, account["name"], "active") - def publish_price_feed( self, symbol, @@ -1092,27 +1256,7 @@ class BitShares(object): }) return self.finalizeOp(op, account["name"], "active") - def upgrade_account(self, account=None): - """ Upgrade an account to Lifetime membership - - :param str account: (optional) the account to allow access - to (defaults to ``default_account``) - """ - if not account: - if "default_account" in config: - account = config["default_account"] - if not account: - raise ValueError("You need to provide an account") - account = Account(account, bitshares_instance=self) - op = operations.Account_upgrade(**{ - "fee": {"amount": 0, "asset_id": "1.3.0"}, - "account_to_upgrade": account["id"], - "upgrade_to_lifetime_member": True, - "prefix": self.rpc.chain_params["prefix"] - }) - return self.finalizeOp(op, account["name"], "active") - - def update_witness(self, witness_identifier, url=None, key=None): + def update_witness(self, witness_identifier, url=None, key=None, **kwargs): """ Upgrade a witness account :param str witness_identifier: Identifier for the witness @@ -1129,9 +1273,9 @@ class BitShares(object): "new_url": url, "new_signing_key": key, }) - return self.finalizeOp(op, account["name"], "active") + return self.finalizeOp(op, account["name"], "active", **kwargs) - def reserve(self, amount, account=None): + def reserve(self, amount, account=None, **kwargs): """ Reserve/Burn an amount of this shares This removes the shares from the supply @@ -1155,7 +1299,7 @@ class BitShares(object): "asset_id": amount["asset"]["id"]}, "extensions": [] }) - return self.finalizeOp(op, account, "active") + return self.finalizeOp(op, account, "active", **kwargs) def create_worker( self, @@ -1166,7 +1310,8 @@ class BitShares(object): begin=None, payment_type="vesting", pay_vesting_period_days=0, - account=None + account=None, + **kwargs ): """ Reserve/Burn an amount of this shares @@ -1220,9 +1365,9 @@ class BitShares(object): "url": url, "initializer": initializer }) - return self.finalizeOp(op, account, "active") + return self.finalizeOp(op, account, "active", **kwargs) - def fund_fee_pool(self, symbol, amount, account=None): + def fund_fee_pool(self, symbol, amount, account=None, **kwargs): """ Fund the fee pool of an asset :param str symbol: The symbol to fund the fee pool of @@ -1245,4 +1390,4 @@ class BitShares(object): "amount": int(float(amount) * 10 ** asset["precision"]), "extensions": [] }) - return self.finalizeOp(op, account, "active") + return self.finalizeOp(op, account, "active", **kwargs) diff --git a/bitshares/block.py b/bitshares/block.py index 79ea6737309bc2cc4a5c17da405251123f93fd2c..4a3af34a5345254e2a02f06a8a2a3082f4aa9b4b 100644 --- a/bitshares/block.py +++ b/bitshares/block.py @@ -1,14 +1,14 @@ -from bitshares.instance import shared_bitshares_instance -from .blockchainobject import BlockchainObject from .exceptions import BlockDoesNotExistsException from .utils import parse_time +from .blockchainobject import BlockchainObject class Block(BlockchainObject): """ Read a single block from the chain :param int block: block number - :param bitshares.bitshares.BitShares bitshares_instance: BitShares instance + :param bitshares.bitshares.BitShares bitshares_instance: BitShares + instance :param bool lazy: Use lazy loading Instances of this class are dictionaries that come with additional diff --git a/bitshares/blockchainobject.py b/bitshares/blockchainobject.py index d47df3a6bfb99b868b77f7b9ab84a12c76f8291b..af90fe7eef7ac724a5783bf03bb39a8e3d576a8f 100644 --- a/bitshares/blockchainobject.py +++ b/bitshares/blockchainobject.py @@ -4,17 +4,21 @@ from datetime import datetime, timedelta class ObjectCache(dict): - max_cache_objects = 1000 - - def __init__(self, initial_data={}, max_cache_objects=1000): + def __init__(self, initial_data={}, default_expiration=10): super().__init__(initial_data) - ObjectCache.max_cache_objects = max_cache_objects + self.default_expiration = default_expiration + + def clear(self): + """ Clears the whole cache + """ + dict.__init__(self, dict()) def __setitem__(self, key, value): if key in self: del self[key] data = { - "expires": datetime.utcnow() + timedelta(seconds=10), + "expires": datetime.utcnow() + timedelta( + seconds=self.default_expiration), "data": value } dict.__setitem__(self, key, data) @@ -38,7 +42,8 @@ class ObjectCache(dict): return False def __str__(self): - return "ObjectCache(n={}, max_cache_objects={})".format(len(self.keys()), self.max_cache_objects) + return "ObjectCache(n={}, default_expiration={})".format( + len(self.keys()), self.default_expiration) class BlockchainObject(dict): @@ -65,21 +70,10 @@ class BlockchainObject(dict): self.cached = False self.identifier = None - def test_valid_objectid(i): - if "." not in i: - return False - parts = i.split(".") - if len(parts) == 3: - try: - [int(x) for x in parts] - return True - except: - pass - return False - # We don't read lists, sets, or tuples if isinstance(data, (list, set, tuple)): - raise ValueError("Cannot interpret lists! Please load elements individually!") + raise ValueError( + "Cannot interpret lists! Please load elements individually!") if klass and isinstance(data, klass): self.identifier = data.get("id") @@ -98,7 +92,7 @@ class BlockchainObject(dict): self.identifier = data else: self.identifier = data - if test_valid_objectid(self.identifier): + if self.test_valid_objectid(self.identifier): # Here we assume we deal with an id self.testid(self.identifier) if self.iscached(data): @@ -110,6 +104,23 @@ class BlockchainObject(dict): self.cache() self.cached = True + @staticmethod + def clear_cache(): + if BlockchainObject._cache: + BlockchainObject._cache.clear() + + def test_valid_objectid(self, i): + if "." not in i: + return False + parts = i.split(".") + if len(parts) == 3: + try: + [int(x) for x in parts] + return True + except: + pass + return False + def testid(self, id): parts = id.split(".") if not self.type_id: @@ -119,9 +130,11 @@ class BlockchainObject(dict): self.type_ids = [self.type_id] assert int(parts[0]) == self.space_id,\ - "Valid id's for {} are {}.{}.x".format(self.__class__.__name__, self.space_id, self.type_ida) + "Valid id's for {} are {}.{}.x".format( + self.__class__.__name__, self.space_id, self.type_id) assert int(parts[1]) in self.type_ids,\ - "Valid id's for {} are {}.{}.x".format(self.__class__.__name__, self.space_id, self.type_ids) + "Valid id's for {} are {}.{}.x".format( + self.__class__.__name__, self.space_id, self.type_ids) def cache(self): # store in cache @@ -150,4 +163,5 @@ class BlockchainObject(dict): return super().__contains__(key) def __repr__(self): - return "<%s %s>" % (self.__class__.__name__, str(self.identifier)) + return "<%s %s>" % ( + self.__class__.__name__, str(self.identifier)) diff --git a/bitshares/committee.py b/bitshares/committee.py index ca17118814989b707f2bc7d547b977b47c18decf..9d0cece7a52ceaeed52ea1cced7c62bae6e41a77 100644 --- a/bitshares/committee.py +++ b/bitshares/committee.py @@ -1,4 +1,3 @@ -from bitshares.instance import shared_bitshares_instance from .account import Account from .exceptions import CommitteeMemberDoesNotExistsException from .blockchainobject import BlockchainObject @@ -8,20 +7,35 @@ class Committee(BlockchainObject): """ Read data about a Committee Member in the chain :param str member: Name of the Committee Member - :param bitshares bitshares_instance: BitShares() instance to use when accesing a RPC + :param bitshares bitshares_instance: BitShares() instance to use when + accesing a RPC :param bool lazy: Use lazy loading """ type_id = 5 def refresh(self): - account = Account(self.identifier) - member = self.bitshares.rpc.get_committee_member_by_account(account["id"]) + if self.test_valid_objectid(self.identifier): + _, i, _ = self.identifier.split(".") + if int(i) == 2: + account = Account(self.identifier) + member = self.bitshares.rpc.get_committee_member_by_account( + account["id"]) + elif int(i) == 5: + member = self.bitshares.rpc.get_object(self.identifier) + else: + raise CommitteeMemberDoesNotExistsException + else: + # maybe identifier is an account name + account = Account(self.identifier) + member = self.bitshares.rpc.get_committee_member_by_account( + account["id"]) + if not member: raise CommitteeMemberDoesNotExistsException super(Committee, self).__init__(member) - self.cached = True + self.account_id = account["id"] @property def account(self): - return Account(self.identifier) + return Account(self.account_id) diff --git a/bitshares/exceptions.py b/bitshares/exceptions.py index 2b5e8288e02d54333f59b55c468a1c63fcfc0775..0443b118398772fd83acb625eb521db33a608543 100644 --- a/bitshares/exceptions.py +++ b/bitshares/exceptions.py @@ -24,68 +24,68 @@ class AssetDoesNotExistsException(Exception): class InvalidAssetException(Exception): - """ The used asset is invalid in this context + """ An invalid asset has been provided """ pass -class BlockDoesNotExistsException(Exception): - """ The block does not exist +class InsufficientAuthorityError(Exception): + """ The transaction requires signature of a higher authority """ pass -class WitnessDoesNotExistsException(Exception): - """ The witness does not exist +class MissingKeyError(Exception): + """ A required key couldn't be found in the wallet """ pass -class CommitteeMemberDoesNotExistsException(Exception): - """ Committee Member does not exist +class InvalidWifError(Exception): + """ The provided private Key has an invalid format """ pass -class VestingBalanceDoesNotExistsException(Exception): - """ Vesting Balance does not exist +class ProposalDoesNotExistException(Exception): + """ The proposal does not exist """ pass -class ProposalDoesNotExistException(Exception): - """ The proposal does not exist +class BlockDoesNotExistsException(Exception): + """ The block does not exist """ pass -class InsufficientAuthorityError(Exception): - """ The transaction requires signature of a higher authority +class NoWalletException(Exception): + """ No Wallet could be found, please use :func:`peerplays.wallet.create` to + create a new wallet """ pass -class MissingKeyError(Exception): - """ A required key couldn't be found in the wallet +class WitnessDoesNotExistsException(Exception): + """ The witness does not exist """ pass -class InvalidWifError(Exception): - """ The provided private Key has an invalid format +class WrongMasterPasswordException(Exception): + """ The password provided could not properly unlock the wallet """ pass -class NoWalletException(Exception): - """ No Wallet could be found, please use :func:`bitshares.wallet.create` to - create a new wallet +class CommitteeMemberDoesNotExistsException(Exception): + """ Committee Member does not exist """ pass -class WrongMasterPasswordException(Exception): - """ The password provided could not properly unlock the wallet +class VestingBalanceDoesNotExistsException(Exception): + """ Vesting Balance does not exist """ pass @@ -94,3 +94,9 @@ class WorkerDoesNotExistsException(Exception): """ Worker does not exist """ pass + + +class ObjectNotInProposalBuffer(Exception): + """ Object was not found in proposal + """ + pass diff --git a/bitshares/instance.py b/bitshares/instance.py index c604f6c6c408238e3eb7f640e65cea0c7f88cd39..72c85a7e4716a8896f4d2112d43b8291e67ea9f1 100644 --- a/bitshares/instance.py +++ b/bitshares/instance.py @@ -1,24 +1,33 @@ import bitshares as bts -_shared_bitshares_instance = None + +class SharedInstance(): + instance = None def shared_bitshares_instance(): - """ This method will initialize ``_shared_bitshares_instance`` and return it. + """ This method will initialize ``SharedInstance.instance`` and return it. The purpose of this method is to have offer single default bitshares instance that can be reused by multiple classes. """ - global _shared_bitshares_instance - if not _shared_bitshares_instance: - _shared_bitshares_instance = bts.BitShares() - return _shared_bitshares_instance + if not SharedInstance.instance: + clear_cache() + SharedInstance.instance = bts.BitShares() + return SharedInstance.instance def set_shared_bitshares_instance(bitshares_instance): """ This method allows us to override default bitshares instance for all users of - ``_shared_bitshares_instance``. + ``SharedInstance.instance``. :param bitshares.bitshares.BitShares bitshares_instance: BitShares instance """ - global _shared_bitshares_instance - _shared_bitshares_instance = bitshares_instance + clear_cache() + SharedInstance.instance = bitshares_instance + + +def clear_cache(): + """ Clear Caches + """ + from .blockchainobject import BlockchainObject + BlockchainObject.clear_cache() diff --git a/bitshares/price.py b/bitshares/price.py index f94f90024ac5f7658dc364817c7e7f5d8dfd9865..8161c45f84cc2b30e586f4cb8503bed77794733a 100644 --- a/bitshares/price.py +++ b/bitshares/price.py @@ -5,7 +5,6 @@ from .account import Account from .amount import Amount from .asset import Asset from .utils import formatTimeString -from .witness import Witness from .utils import parse_time @@ -393,7 +392,6 @@ class Order(Price): 'deleted' key which is set to ``True`` and all other data be ``None``. """ - def __init__(self, *args, bitshares_instance=None, **kwargs): self.bitshares = bitshares_instance or shared_bitshares_instance() diff --git a/bitshares/transactionbuilder.py b/bitshares/transactionbuilder.py index fdae81ee07b75fd9f67c2ffd4edc6f7aea89480b..17f1a6f71e826cd430a8001875a7f5bd811de05e 100644 --- a/bitshares/transactionbuilder.py +++ b/bitshares/transactionbuilder.py @@ -13,6 +13,116 @@ import logging log = logging.getLogger(__name__) +class ProposalBuilder: + """ Proposal Builder allows us to construct an independent Proposal + that may later be added to an instance ot TransactionBuilder + + :param str proposer: Account name of the proposing user + :param int proposal_expiration: Number seconds until the proposal is + supposed to expire + :param int proposal_review: Number of seconds for review of the + proposal + :param bitshares.transactionbuilder.TransactionBuilder: Specify + your own instance of transaction builder (optional) + :param bitshares.bitshares.BitShares bitshares_instance: BitShares + instance + """ + def __init__( + self, + proposer, + proposal_expiration=None, + proposal_review=None, + parent=None, + bitshares_instance=None, + *args, + **kwargs + ): + self.bitshares = bitshares_instance or shared_bitshares_instance() + + self.set_expiration(proposal_expiration or 2 * 24 * 60 * 60) + self.set_review(proposal_review) + self.set_parent(parent) + self.set_proposer(proposer) + self.ops = list() + + def is_empty(self): + return not (len(self.ops) > 0) + + def set_proposer(self, p): + self.proposer = p + + def set_expiration(self, p): + self.proposal_expiration = p + + def set_review(self, p): + self.proposal_review = p + + def set_parent(self, p): + self.parent = p + + def appendOps(self, ops, append_to=None): + """ Append op(s) to the transaction builder + + :param list ops: One or a list of operations + """ + if isinstance(ops, list): + self.ops.extend(ops) + else: + self.ops.append(ops) + parent = self.parent + if parent: + parent._set_require_reconstruction() + + def list_operations(self): + return [Operation(o) for o in self.ops] + + def broadcast(self): + assert self.parent, "No parent transaction provided!" + self.parent._set_require_reconstruction() + return self.parent.broadcast() + + def get_parent(self): + """ This allows to referr to the actual parent of the Proposal + """ + return self.parent + + def __repr__(self): + return "<Proposal ops=%s>" % str(self.ops) + + def json(self): + """ Return the json formated version of this proposal + """ + raw = self.get_raw() + if not raw: + return dict() + return raw.json() + + def get_raw(self): + """ Returns an instance of base "Operations" for further processing + """ + if not self.ops: + return + ops = [operations.Op_wrapper(op=o) for o in list(self.ops)] + proposer = Account( + self.proposer, + bitshares_instance=self.bitshares + ) + data = { + "fee": {"amount": 0, "asset_id": "1.3.0"}, + "fee_paying_account": proposer["id"], + "expiration_time": transactions.formatTimeFromNow( + self.proposal_expiration), + "proposed_ops": [o.json() for o in ops], + "extensions": [] + } + if self.proposal_review: + data.update({ + "review_period_seconds": self.proposal_review + }) + ops = operations.Proposal_create(**data) + return Operation(ops) + + class TransactionBuilder(dict): """ This class simplifies the creation of transactions by adding operations and signers. @@ -31,22 +141,51 @@ class TransactionBuilder(dict): # Do we need to reconstruct the tx from self.ops? self._require_reconstruction = True - def is_signed(self): + def is_empty(self): + return not (len(self.ops) > 0) + + def list_operations(self): + return [Operation(o) for o in self.ops] + + def _is_signed(self): return "signatures" in self and self["signatures"] - def is_constructed(self): + def _is_constructed(self): return "expiration" in self and self["expiration"] - def is_require_reconstruction(self): + def _is_require_reconstruction(self): return self._require_reconstruction - def set_require_reconstruction(self): + def _set_require_reconstruction(self): self._require_reconstruction = True - def unset_require_reconstruction(self): + def _unset_require_reconstruction(self): self._require_reconstruction = False - def appendOps(self, ops): + def __repr__(self): + return str(self) + + def __str__(self): + return str(self.json()) + + def __getitem__(self, key): + if key not in self: + self.constructTx() + return dict(self).__getitem__(key) + + def get_parent(self): + """ TransactionBuilders don't have parents, they are their own parent + """ + return self + + def json(self): + """ Show the transaction as plain json + """ + if not self._is_constructed() or self._is_require_reconstruction(): + self.constructTx() + return dict(self) + + def appendOps(self, ops, append_to=None): """ Append op(s) to the transaction builder :param list ops: One or a list of operations @@ -55,7 +194,7 @@ class TransactionBuilder(dict): self.ops.extend(ops) else: self.ops.append(ops) - self.set_require_reconstruction() + self._set_require_reconstruction() def appendSigner(self, account, permission): """ Try to obtain the wif key from the wallet by telling which account @@ -84,10 +223,10 @@ class TransactionBuilder(dict): return r - if account not in self.available_signers: + if account not in self.signing_accounts: # is the account an instance of public key? if isinstance(account, PublicKey): - self.wifs.append( + self.wifs.add( self.bitshares.wallet.getPrivateKeyForPublicKey( str(account) ) @@ -98,9 +237,10 @@ class TransactionBuilder(dict): keys = fetchkeys(account, permission) if permission != "owner": keys.extend(fetchkeys(account, "owner")) - self.wifs.extend([x[0] for x in keys]) + for x in keys: + self.wifs.add(x[0]) - self.available_signers.append(account) + self.signing_accounts.append(account) def appendWif(self, wif): """ Add a wif that should be used for signing of the transaction. @@ -108,7 +248,7 @@ class TransactionBuilder(dict): if wif: try: PrivateKey(wif) - self.wifs.append(wif) + self.wifs.add(wif) except: raise InvalidWifError @@ -116,25 +256,19 @@ class TransactionBuilder(dict): """ Construct the actual transaction and store it in the class's dict store """ - if self.bitshares.proposer: - ops = [operations.Op_wrapper(op=o) for o in list(self.ops)] - proposer = Account( - self.bitshares.proposer, - bitshares_instance=self.bitshares - ) - ops = operations.Proposal_create(**{ - "fee": {"amount": 0, "asset_id": "1.3.0"}, - "fee_paying_account": proposer["id"], - "expiration_time": transactions.formatTimeFromNow( - self.bitshares.proposal_expiration), - "proposed_ops": [o.json() for o in ops], - "review_period_seconds": self.bitshares.proposal_review, - "extensions": [] - }) - ops = [Operation(ops)] - else: - ops = [Operation(o) for o in list(self.ops)] + ops = list() + for op in self.ops: + if isinstance(op, ProposalBuilder): + # This operation is a proposal an needs to be deal with + # differently + proposals = op.get_raw() + if proposals: + ops.append(proposals) + else: + # otherwise, we simply wrap ops into Operations + ops.extend([Operation(op)]) + # We no wrap everything into an actual transaction ops = transactions.addRequiredFees(self.bitshares.rpc, ops) expiration = transactions.formatTimeFromNow(self.bitshares.expiration) ref_block_num, ref_block_prefix = transactions.getBlockParams( @@ -146,7 +280,7 @@ class TransactionBuilder(dict): operations=ops ) super(TransactionBuilder, self).__init__(self.tx.json()) - self.unset_require_reconstruction() + self._unset_require_reconstruction() def sign(self): """ Sign a provided transaction witht he provided key(s) @@ -159,12 +293,17 @@ class TransactionBuilder(dict): """ self.constructTx() + if "operations" not in self or not self["operations"]: + return + + # Legacy compatibility! # If we are doing a proposal, obtain the account from the proposer_id if self.bitshares.proposer: proposer = Account( self.bitshares.proposer, bitshares_instance=self.bitshares) - self.wifs = [] + self.wifs = set() + self.signing_accounts = list() self.appendSigner(proposer["id"], "active") # We need to set the default prefix, otherwise pubkeys are @@ -200,9 +339,13 @@ class TransactionBuilder(dict): :param tx tx: Signed transaction to broadcast """ - if not self.is_signed(): + # Cannot broadcast an empty transaction + if not self._is_signed(): self.sign() + if "operations" not in self or not self["operations"]: + return + ret = self.json() if self.bitshares.nobroadcast: @@ -229,9 +372,9 @@ class TransactionBuilder(dict): """ Clear the transaction builder and start from scratch """ self.ops = [] - self.wifs = [] - self.available_signers = [] - # This makes sure that is_constructed will return False afterwards + self.wifs = set() + self.signing_accounts = [] + # This makes sure that _is_constructed will return False afterwards self["expiration"] = None super(TransactionBuilder, self).__init__({}) @@ -275,13 +418,6 @@ class TransactionBuilder(dict): [x[0] for x in account_auth_account[permission]["key_auths"]] ) - def json(self): - """ Show the transaction as plain json - """ - if not self.is_constructed() or self.is_require_reconstruction(): - self.constructTx() - return dict(self) - def appendMissingSignatures(self): """ Store which accounts/keys are supposed to sign the transaction diff --git a/bitshares/utils.py b/bitshares/utils.py index 0546a4a0f28d05150a03401d598dca73a6407c32..52e27e1e36723a6d9919bfa1e449c94d72e96461 100644 --- a/bitshares/utils.py +++ b/bitshares/utils.py @@ -1,5 +1,6 @@ import time from datetime import datetime +from .exceptions import ObjectNotInProposalBuffer timeFormat = '%Y-%m-%dT%H:%M:%S' @@ -28,10 +29,38 @@ def formatTimeFromNow(secs=0): :rtype: str """ - return datetime.utcfromtimestamp(time.time() + int(secs)).strftime(timeFormat) + return datetime.utcfromtimestamp( + time.time() + int(secs)).strftime(timeFormat) def parse_time(block_time): - """Take a string representation of time from the blockchain, and parse it into datetime object. + """Take a string representation of time from the blockchain, and parse it + into datetime object. """ return datetime.strptime(block_time, timeFormat) + + +def test_proposal_in_buffer(buf, operation_name, id): + from .transactionbuilder import ProposalBuilder + from peerplaysbase.operationids import operations + assert isinstance(buf, ProposalBuilder) + + operationid = operations.get(operation_name) + _, _, j = id.split(".") + + ops = buf.list_operations() + if (len(ops) <= int(j)): + raise ObjectNotInProposalBuffer( + "{} with id {} not found".format( + operation_name, + id + ) + ) + op = ops[int(j)].json() + if op[0] != operationid: + raise ObjectNotInProposalBuffer( + "{} with id {} not found".format( + operation_name, + id + ) + ) diff --git a/bitshares/wallet.py b/bitshares/wallet.py index 355d93b1632f8880c56b2f70d6a43a3a6e381844..8a26a0a96457475118f3bc2ae1816c936d0faf05 100644 --- a/bitshares/wallet.py +++ b/bitshares/wallet.py @@ -19,7 +19,8 @@ class Wallet(): or uses a SQLite database managed by storage.py. :param BitSharesNodeRPC rpc: RPC connection to a BitShares node - :param array,dict,string keys: Predefine the wif keys to shortcut the wallet database + :param array,dict,string keys: Predefine the wif keys to shortcut the + wallet database Three wallet operation modes are possible: @@ -84,7 +85,8 @@ class Wallet(): """ This method is strictly only for in memory keys that are passed to Wallet/BitShares with the ``keys`` argument """ - log.debug("Force setting of private keys. Not using the wallet database!") + log.debug( + "Force setting of private keys. Not using the wallet database!") if isinstance(loadkeys, dict): Wallet.keyMap = loadkeys loadkeys = list(loadkeys.values()) @@ -168,7 +170,8 @@ class Wallet(): """ Encrypt a wif key """ assert not self.locked() - return format(bip38.encrypt(PrivateKey(wif), self.masterpassword), "encwif") + return format( + bip38.encrypt(PrivateKey(wif), self.masterpassword), "encwif") def decrypt_wif(self, encwif): """ decrypt a wif key @@ -185,13 +188,15 @@ class Wallet(): def addPrivateKey(self, wif): """ Add a private key to the wallet database """ - # it could be either graphenebase or bitsharesbase so we can't check the type directly + # it could be either graphenebase or peerplaysbase so we can't check + # the type directly if isinstance(wif, PrivateKey) or isinstance(wif, GPHPrivateKey): wif = str(wif) try: pub = format(PrivateKey(wif).pubkey, self.prefix) except: - raise InvalidWifError("Invalid Private Key Format. Please use WIF!") + raise InvalidWifError( + "Invalid Private Key Format. Please use WIF!") if self.keyStorage: # Test if wallet exists @@ -217,7 +222,8 @@ class Wallet(): if not self.created(): raise NoWalletException - return self.decrypt_wif(self.keyStorage.getPrivateKeyForPublicKey(pub)) + return self.decrypt_wif( + self.keyStorage.getPrivateKeyForPublicKey(pub)) def removePrivateKeyFromPublicKey(self, pub): """ Remove a key from the wallet database @@ -260,7 +266,8 @@ class Wallet(): account = self.rpc.get_account(name) if not account: return - key = self.getPrivateKeyForPublicKey(account["options"]["memo_key"]) + key = self.getPrivateKeyForPublicKey( + account["options"]["memo_key"]) if key: return key return False @@ -286,8 +293,16 @@ class Wallet(): pub = format(PrivateKey(wif).pubkey, self.prefix) return self.getAccountFromPublicKey(pub) + def getAccountsFromPublicKey(self, pub): + """ Obtain all accounts associated with a public key + """ + names = self.rpc.get_key_references([pub]) + for name in names: + for i in name: + yield i + def getAccountFromPublicKey(self, pub): - """ Obtain account name from public key + """ Obtain the first account name from public key """ # FIXME, this only returns the first associated key. # If the key is used by multiple accounts, this @@ -298,26 +313,36 @@ class Wallet(): else: return names[0] + def getAllAccounts(self, pub): + """ Get the account data for a public key (all accounts found for this + public key) + """ + for id in self.getAccountsFromPublicKey(pub): + try: + account = Account(id) + except: + continue + yield {"name": account["name"], + "account": account, + "type": self.getKeyType(account, pub), + "pubkey": pub} + def getAccount(self, pub): - """ Get the account data for a public key + """ Get the account data for a public key (first account found for this + public key) """ name = self.getAccountFromPublicKey(pub) if not name: - return {"name": None, - "type": None, - "pubkey": pub - } + return {"name": None, "type": None, "pubkey": pub} else: try: account = Account(name) except: return - keyType = self.getKeyType(account, pub) return {"name": account["name"], "account": account, - "type": keyType, - "pubkey": pub - } + "type": self.getKeyType(account, pub), + "pubkey": pub} def getKeyType(self, account, pub): """ Get key type @@ -338,7 +363,7 @@ class Wallet(): for pubkey in pubkeys: # Filter those keys not for our network if pubkey[:len(self.prefix)] == self.prefix: - accounts.append(self.getAccount(pubkey)) + accounts.extend(self.getAllAccounts(pubkey)) return accounts def getPublicKeys(self): diff --git a/bitshares/witness.py b/bitshares/witness.py index b127e3124e2a4df6cf1dacd06e87986dfb3753e0..dff4ca44a74e7ab2aa6789b89d118f0bc3bed1cc 100644 --- a/bitshares/witness.py +++ b/bitshares/witness.py @@ -8,26 +8,23 @@ class Witness(BlockchainObject): """ Read data about a witness in the chain :param str account_name: Name of the witness - :param bitshares bitshares_instance: BitShares() instance to use when accesing a RPC + :param bitshares bitshares_instance: BitShares() instance to use when + accesing a RPC """ type_ids = [6, 2] def refresh(self): - parts = self.identifier.split(".") - valid_objectid = False - try: - [int(x) for x in parts] - valid_objectid = True - except: - pass - if valid_objectid and len(parts) > 2: - if int(parts[1]) == 6: + if self.test_valid_objectid(self.identifier): + _, i, _ = self.identifier.split(".") + if int(i) == 6: witness = self.bitshares.rpc.get_object(self.identifier) else: - witness = self.bitshares.rpc.get_witness_by_account(self.identifier) + witness = self.bitshares.rpc.get_witness_by_account( + self.identifier) else: - account = Account(self.identifier, bitshares_instance=self.bitshares) + account = Account( + self.identifier, bitshares_instance=self.bitshares) witness = self.bitshares.rpc.get_witness_by_account(account["id"]) if not witness: raise WitnessDoesNotExistsException @@ -41,11 +38,13 @@ class Witness(BlockchainObject): class Witnesses(list): """ Obtain a list of **active** witnesses and the current schedule - :param bitshares bitshares_instance: BitShares() instance to use when accesing a RPC + :param bitshares bitshares_instance: BitShares() instance to use when + accesing a RPC """ def __init__(self, bitshares_instance=None): self.bitshares = bitshares_instance or shared_bitshares_instance() - self.schedule = self.bitshares.rpc.get_object("2.12.0").get("current_shuffled_witnesses", []) + self.schedule = self.bitshares.rpc.get_object( + "2.12.0").get("current_shuffled_witnesses", []) super(Witnesses, self).__init__( [ diff --git a/bitsharesbase/objects.py b/bitsharesbase/objects.py index 38b8fe6a34ae73e660bdabb1e1809b611e748a4e..89b9fba023da033abbfa55c0ac416b18e7e2a799 100644 --- a/bitsharesbase/objects.py +++ b/bitsharesbase/objects.py @@ -88,20 +88,8 @@ class Memo(GrapheneObject): else: if len(args) == 1 and len(kwargs) == 0: kwargs = args[0] + prefix = kwargs.pop("prefix", default_prefix) if "message" in kwargs and kwargs["message"]: - if "chain" not in kwargs: - chain = default_prefix - else: - chain = kwargs["chain"] - if isinstance(chain, str) and chain in known_chains: - chain_params = known_chains[chain] - elif isinstance(chain, dict): - chain_params = chain - else: - raise Exception("Memo() only takes a string or a dict as chain!") - if "prefix" not in chain_params: - raise Exception("Memo() needs a 'prefix' in chain params!") - prefix = chain_params["prefix"] super().__init__(OrderedDict([ ('from', PublicKey(kwargs["from"], prefix=prefix)), ('to', PublicKey(kwargs["to"], prefix=prefix)), diff --git a/bitsharesbase/operations.py b/bitsharesbase/operations.py index 157f74ca53afc8eb373fec65df3d477caee9aa94..32a77bef72a2a2c57d0aeb18f5c7b13375281aca 100644 --- a/bitsharesbase/operations.py +++ b/bitsharesbase/operations.py @@ -39,13 +39,19 @@ def getOperationNameForId(i): class Transfer(GrapheneObject): def __init__(self, *args, **kwargs): + # Allow for overwrite of prefix if isArgsThisClass(self, args): self.data = args[0].data else: if len(args) == 1 and len(kwargs) == 0: kwargs = args[0] + prefix = kwargs.get("prefix", default_prefix) if "memo" in kwargs and kwargs["memo"]: - memo = Optional(Memo(kwargs["memo"])) + if isinstance(kwargs["memo"], dict): + kwargs["memo"]["prefix"] = prefix + memo = Optional(Memo(**kwargs["memo"])) + else: + memo = Optional(Memo(kwargs["memo"])) else: memo = Optional(None) super().__init__(OrderedDict([ diff --git a/requirements-test.txt b/requirements-test.txt index d8696895da3095eb60bedfb6f9acf7120f074914..bad8ffde726bf6c1e78cb30202ed206b1e6efdba 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -4,4 +4,5 @@ scrypt==0.7.1 Events==0.2.2 pyyaml pytest -coverage \ No newline at end of file +coverage +mock diff --git a/setup.py b/setup.py index 588ac31de95fe7b993f4ca2e5c3cb1e60f7ae4cf..3cc4ea46dc1651aae4079b28494faed20be0556c 100755 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ setup( 'Topic :: Office/Business :: Financial', ], install_requires=[ - "graphenelib>=0.5.3", + "graphenelib>=0.5.5", "websockets", "appdirs", "Events", diff --git a/tests/.ropeproject/globalnames b/tests/.ropeproject/globalnames new file mode 100644 index 0000000000000000000000000000000000000000..08fd0f766b4e0b248fa073425328342f538b4bcd --- /dev/null +++ b/tests/.ropeproject/globalnames @@ -0,0 +1,4 @@ +€}q(Ubitsharesbase.operations]q(Udefault_prefixqUAsset_fund_fee_poolqUTransferqUAsset_publish_feedqUBid_collateralqUgetOperationNameForIdqUAccount_updateq U Asset_reserveq +U Worker_createqUAccount_upgradeqUAsset_update_feed_producersq UProposal_updateqUAccount_createqUVesting_balance_withdrawqUCall_order_updateqULimit_order_cancelqUAccount_whitelistqUWitness_updateqU +Op_wrapperqUOverride_transferqUProposal_createqUAsset_updateqULimit_order_createqeUpeerplays.committee]qU CommitteeqaUtest_amount]qU TestcasesqaUtest_txbuffers]q(UwifqheUbitshares.transactionbuilder]q (Ulogq!UProposalBuilderq"UTransactionBuilderq#eUbitshares.bitshares]q$(h!U BitSharesq%eUbitshares.exceptions]q&(UNoWalletExceptionq'UBlockDoesNotExistsExceptionq(UAssetDoesNotExistsExceptionq)UInsufficientAuthorityErrorq*U%CommitteeMemberDoesNotExistsExceptionq+UMissingKeyErrorq,UWorkerDoesNotExistsExceptionq-UProposalDoesNotExistExceptionq.UInvalidAssetExceptionq/UWitnessDoesNotExistsExceptionq0UWalletExistsq1UWrongMasterPasswordExceptionq2UInvalidWifErrorq3U$VestingBalanceDoesNotExistsExceptionq4UObjectNotInProposalBufferq5UAccountDoesNotExistsExceptionq6UAccountExistsExceptionq7eUtest_proposals]q8(hheUbitshares.price]q9(U PriceFeedq:UUpdateCallOrderq;UOrderq<UFilledOrderq=UPriceq>eUbitsharesbase.objects]q?(hh>h:UAccountOptionsq@UWorker_initializerqAUMemoqBU +PermissionqCUAssetqDU AccountIdqEUAccountCreateExtensionsqFUAssetIdqGUSpecialAuthorityqHU ExtensionqIU OperationqJUAssetOptionsqKUObjectIdqLeUbitshares.account]qM(UAccountqNU AccountUpdateqOeUtest_objectcache]qPhaUtest_bitshares]qQ(hhU core_unitqReUtest_wallet]qS(hheUpeerplays.witness]qT(U WitnessesqUUWitnessqVeu. \ No newline at end of file diff --git a/tests/.ropeproject/history b/tests/.ropeproject/history new file mode 100644 index 0000000000000000000000000000000000000000..fcd9c963caaed6b4d36d014b5595385deef83d9e --- /dev/null +++ b/tests/.ropeproject/history @@ -0,0 +1 @@ +€]q(]q]qe. \ No newline at end of file diff --git a/tests/.ropeproject/objectdb b/tests/.ropeproject/objectdb new file mode 100644 index 0000000000000000000000000000000000000000..29c40cda94351229c1ed18850732694b1aa17920 --- /dev/null +++ b/tests/.ropeproject/objectdb @@ -0,0 +1 @@ +€}q. \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/test_account.py b/tests/test_account.py new file mode 100644 index 0000000000000000000000000000000000000000..6dfe2aa830a26d416572edf23712c3bdfba74188 --- /dev/null +++ b/tests/test_account.py @@ -0,0 +1,71 @@ +import unittest +import mock +from pprint import pprint +from bitshares import BitShares +from bitshares.account import Account +from bitshares.amount import Amount +from bitshares.asset import Asset +from bitshares.instance import set_shared_bitshares_instance +from bitsharesbase.operationids import getOperationNameForId + +wif = "5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3" + + +class Testcases(unittest.TestCase): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.bts = BitShares( + "wss://node.testnet.bitshares.eu", + nobroadcast=True, + # We want to bundle many operations into a single transaction + bundle=True, + # Overwrite wallet to use this list of wifs only + wif={"active": wif} + ) + self.bts.set_default_account("init0") + set_shared_bitshares_instance(self.bts) + + def test_account(self): + Account("witness-account") + Account("1.2.3") + asset = Asset("1.3.0") + symbol = asset["symbol"] + account = Account("witness-account", full=True) + self.assertEqual(account.name, "witness-account") + self.assertEqual(account["name"], account.name) + self.assertEqual(account["id"], "1.2.1") + self.assertIsInstance(account.balance("1.3.0"), Amount) + # self.assertIsInstance(account.balance({"symbol": symbol}), Amount) + self.assertIsInstance(account.balances, list) + for h in account.history(limit=1): + pass + + # BlockchainObjects method + account.cached = False + self.assertTrue(account.items()) + account.cached = False + self.assertIn("id", account) + account.cached = False + self.assertEqual(account["id"], "1.2.1") + self.assertEqual(str(account), "<Account 1.2.1>") + self.assertIsInstance(Account(account), Account) + + def test_account_upgrade(self): + account = Account("witness-account") + tx = account.upgrade() + ops = tx["operations"] + op = ops[0][1] + self.assertEqual(len(ops), 1) + self.assertEqual( + getOperationNameForId(ops[0][0]), + "account_upgrade" + ) + self.assertTrue( + op["upgrade_to_lifetime_member"] + ) + self.assertEqual( + op["account_to_upgrade"], + "1.2.1", + ) diff --git a/tests/test_aes.py b/tests/test_aes.py new file mode 100644 index 0000000000000000000000000000000000000000..e2ad8e19f8d4629a667fc08ab11aa302592c285d --- /dev/null +++ b/tests/test_aes.py @@ -0,0 +1,51 @@ +import string +import random +import unittest +import base64 +from pprint import pprint +from bitshares.aes import AESCipher + + +class Testcases(unittest.TestCase): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.aes = AESCipher("Foobar") + + def test_str(self): + self.assertIsInstance(AESCipher.str_to_bytes("foobar"), bytes) + self.assertIsInstance(AESCipher.str_to_bytes(b"foobar"), bytes) + + def test_key(self): + self.assertEqual( + base64.b64encode(self.aes.key), + b"6BGBj4DZw8ItV3uoPWGWeI5VO7QIU1u0IQXN/3JqYKs=" + ) + + def test_pad(self): + self.assertEqual( + base64.b64encode(self.aes._pad(b"123456")), + b"MTIzNDU2GhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGho=" + ) + + def test_unpad(self): + self.assertEqual( + self.aes._unpad(base64.b64decode(b"MTIzNDU2GhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGho=")), + b"123456" + ) + + def test_padding(self): + for n in range(1, 64): + name = ''.join(random.choice(string.ascii_lowercase) for _ in range(n)) + self.assertEqual( + self.aes._unpad(self.aes._pad( + bytes(name, "utf-8"))), + bytes(name, "utf-8") + ) + + def test_encdec(self): + for n in range(1, 16): + name = ''.join(random.choice(string.ascii_lowercase) for _ in range(64)) + self.assertEqual( + self.aes.decrypt(self.aes.encrypt(name)), + name) diff --git a/tests/test_amount.py b/tests/test_amount.py new file mode 100644 index 0000000000000000000000000000000000000000..e255a95a88ac96834001379bbac35f2a5da90770 --- /dev/null +++ b/tests/test_amount.py @@ -0,0 +1,225 @@ +import unittest +from bitshares import BitShares +from bitshares.amount import Amount +from bitshares.asset import Asset +from bitshares.instance import set_shared_bitshares_instance, SharedInstance + + +class Testcases(unittest.TestCase): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.bts = BitShares( + "wss://node.testnet.bitshares.eu", + nobroadcast=True, + ) + set_shared_bitshares_instance(self.bts) + self.asset = Asset("1.3.0") + self.symbol = self.asset["symbol"] + self.precision = self.asset["precision"] + self.asset2 = Asset("1.3.1") + + def dotest(self, ret, amount, symbol): + self.assertEqual(float(ret), float(amount)) + self.assertEqual(ret["symbol"], symbol) + self.assertIsInstance(ret["asset"], dict) + self.assertIsInstance(ret["amount"], float) + + def test_init(self): + # String init + amount = Amount("1 {}".format(self.symbol)) + self.dotest(amount, 1, self.symbol) + + # Amount init + amount = Amount(amount) + self.dotest(amount, 1, self.symbol) + + # blockchain dict init + amount = Amount({ + "amount": 1 * 10 ** self.precision, + "asset_id": self.asset["id"] + }) + self.dotest(amount, 1, self.symbol) + + # API dict init + amount = Amount({ + "amount": 1.3 * 10 ** self.precision, + "asset": self.asset["id"] + }) + self.dotest(amount, 1.3, self.symbol) + + # Asset as symbol + amount = Amount(1.3, Asset("1.3.0")) + self.dotest(amount, 1.3, self.symbol) + + # Asset as symbol + amount = Amount(1.3, self.symbol) + self.dotest(amount, 1.3, self.symbol) + + # keyword inits + amount = Amount(amount=1.3, asset=Asset("1.3.0")) + self.dotest(amount, 1.3, self.symbol) + + # keyword inits + amount = Amount(amount=1.3, asset=dict(Asset("1.3.0"))) + self.dotest(amount, 1.3, self.symbol) + + # keyword inits + amount = Amount(amount=1.3, asset=self.symbol) + self.dotest(amount, 1.3, self.symbol) + + def test_copy(self): + amount = Amount("1", self.symbol) + self.dotest(amount.copy(), 1, self.symbol) + + def test_properties(self): + amount = Amount("1", self.symbol) + self.assertEqual(amount.amount, 1.0) + self.assertEqual(amount.symbol, self.symbol) + self.assertIsInstance(amount.asset, Asset) + self.assertEqual(amount.asset["symbol"], self.symbol) + + def test_tuple(self): + amount = Amount("1", self.symbol) + self.assertEqual( + amount.tuple(), + (1.0, self.symbol)) + + def test_json(self): + amount = Amount("1", self.symbol) + self.assertEqual( + amount.json(), + { + "asset_id": self.asset["id"], + "amount": 1 * 10 ** self.precision + }) + + def test_string(self): + self.assertEqual( + str(Amount("1", self.symbol)), + "1.00000 {}".format(self.symbol)) + + def test_int(self): + self.assertEqual( + int(Amount("1", self.symbol)), + 100000) + + def test_float(self): + self.assertEqual( + float(Amount("1", self.symbol)), + 1.00000) + + def test_plus(self): + a1 = Amount(1, self.symbol) + a2 = Amount(2, self.symbol) + self.dotest(a1 + a2, 3, self.symbol) + with self.assertRaises(Exception): + a1 + Amount(1, asset=self.asset2) + # inline + a2 = Amount(2, self.symbol) + a2 += a1 + self.dotest(a2, 3, self.symbol) + a2 += 5 + self.dotest(a2, 8, self.symbol) + with self.assertRaises(Exception): + a1 += Amount(1, asset=self.asset2) + + def test_minus(self): + a1 = Amount(1, self.symbol) + a2 = Amount(2, self.symbol) + self.dotest(a1 - a2, -1, self.symbol) + self.dotest(a1 - 5, -4, self.symbol) + with self.assertRaises(Exception): + a1 - Amount(1, asset=self.asset2) + # inline + a2 = Amount(2, self.symbol) + a2 -= a1 + self.dotest(a2, 1, self.symbol) + a2 -= 1 + self.dotest(a2, 0, self.symbol) + self.dotest(a2 - 2, -2, self.symbol) + with self.assertRaises(Exception): + a1 -= Amount(1, asset=self.asset2) + + def test_mul(self): + a1 = Amount(5, self.symbol) + a2 = Amount(2, self.symbol) + self.dotest(a1 * a2, 10, self.symbol) + self.dotest(a1 * 3, 15, self.symbol) + with self.assertRaises(Exception): + a1 * Amount(1, asset=self.asset2) + # inline + a2 = Amount(2, self.symbol) + a2 *= 5 + self.dotest(a2, 10, self.symbol) + with self.assertRaises(Exception): + a1 *= Amount(2, asset=self.asset2) + + def test_div(self): + a1 = Amount(15, self.symbol) + self.dotest(a1 / 3, 5, self.symbol) + self.dotest(a1 // 2, 7, self.symbol) + with self.assertRaises(Exception): + a1 / Amount(1, asset=self.asset2) + # inline + a2 = a1.copy() + a2 /= 3 + self.dotest(a2, 5, self.symbol) + a2 = a1.copy() + a2 //= 2 + self.dotest(a2, 7, self.symbol) + with self.assertRaises(Exception): + a1 *= Amount(2, asset=self.asset2) + + def test_mod(self): + a1 = Amount(15, self.symbol) + self.dotest(a1 % 3, 0, self.symbol) + self.dotest(a1 % 2, 1, self.symbol) + with self.assertRaises(Exception): + a1 % Amount(1, asset=self.asset2) + # inline + a2 = a1.copy() + a2 %= 3 + self.dotest(a2, 0, self.symbol) + with self.assertRaises(Exception): + a1 %= Amount(2, asset=self.asset2) + + def test_pow(self): + a1 = Amount(15, self.symbol) + self.dotest(a1 ** 3, 15 ** 3, self.symbol) + self.dotest(a1 ** 2, 15 ** 2, self.symbol) + with self.assertRaises(Exception): + a1 ** Amount(1, asset=self.asset2) + # inline + a2 = a1.copy() + a2 **= 3 + self.dotest(a2, 15 ** 3, self.symbol) + with self.assertRaises(Exception): + a1 **= Amount(2, asset=self.asset2) + + def test_ltge(self): + a1 = Amount(1, self.symbol) + a2 = Amount(2, self.symbol) + self.assertTrue(a1 < a2) + self.assertTrue(a2 > a1) + self.assertTrue(a2 > 1) + self.assertTrue(a1 < 5) + + def test_leeq(self): + a1 = Amount(1, self.symbol) + a2 = Amount(1, self.symbol) + self.assertTrue(a1 <= a2) + self.assertTrue(a1 >= a2) + self.assertTrue(a1 <= 1) + self.assertTrue(a1 >= 1) + + def test_ne(self): + a1 = Amount(1, self.symbol) + a2 = Amount(2, self.symbol) + self.assertTrue(a1 != a2) + self.assertTrue(a1 != 5) + a1 = Amount(1, self.symbol) + a2 = Amount(1, self.symbol) + self.assertTrue(a1 == a2) + self.assertTrue(a1 == 1) diff --git a/tests/test_asset.py b/tests/test_asset.py new file mode 100644 index 0000000000000000000000000000000000000000..21e06383513cb27705e726f254d616530811a2b6 --- /dev/null +++ b/tests/test_asset.py @@ -0,0 +1,39 @@ +import unittest +from bitshares import BitShares +from bitshares.asset import Asset +from bitshares.instance import set_shared_bitshares_instance +from bitshares.exceptions import AssetDoesNotExistsException + + +class Testcases(unittest.TestCase): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.bts = BitShares( + nobroadcast=True, + ) + set_shared_bitshares_instance(self.bts) + + def test_assert(self): + with self.assertRaises(AssetDoesNotExistsException): + Asset("FOObarNonExisting", full=False) + + def test_refresh(self): + asset = Asset("1.3.0", full=False) + asset.ensure_full() + self.assertIn("dynamic_asset_data", asset) + self.assertIn("flags", asset) + self.assertIn("permissions", asset) + self.assertIsInstance(asset["flags"], dict) + self.assertIsInstance(asset["permissions"], dict) + + def test_properties(self): + asset = Asset("1.3.0", full=False) + self.assertIsInstance(asset.symbol, str) + self.assertIsInstance(asset.precision, int) + self.assertIsInstance(asset.is_bitasset, bool) + self.assertIsInstance(asset.permissions, dict) + self.assertEqual(asset.permissions, asset["permissions"]) + self.assertIsInstance(asset.flags, dict) + self.assertEqual(asset.flags, asset["flags"]) diff --git a/tests/test_base_objects.py b/tests/test_base_objects.py new file mode 100644 index 0000000000000000000000000000000000000000..ca7c29006610453f3a7bac0a342e4f66af2f2abc --- /dev/null +++ b/tests/test_base_objects.py @@ -0,0 +1,31 @@ +import unittest +from bitshares import BitShares, exceptions +from bitshares.instance import set_shared_bitshares_instance +from bitshares.account import Account +from bitshares.committee import Committee + + +class Testcases(unittest.TestCase): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.bts = BitShares( + nobroadcast=True, + ) + set_shared_bitshares_instance(self.bts) + + def test_Committee(self): + with self.assertRaises( + exceptions.AccountDoesNotExistsException + ): + Committee("FOObarNonExisting") + + c = Committee("init0") + self.assertEqual(c["id"], "1.5.0") + self.assertIsInstance(c.account, Account) + + with self.assertRaises( + exceptions.CommitteeMemberDoesNotExistsException + ): + Committee("nathan") diff --git a/tests/test_bitshares.py b/tests/test_bitshares.py new file mode 100644 index 0000000000000000000000000000000000000000..dcdf5374206ce5ff5dfe8ce1dc6a3c721b68a3af --- /dev/null +++ b/tests/test_bitshares.py @@ -0,0 +1,227 @@ +import string +import unittest +import random +from pprint import pprint +from bitshares import BitShares +from bitsharesbase.operationids import getOperationNameForId +from bitshares.amount import Amount +from bitsharesbase.account import PrivateKey +from bitshares.instance import set_shared_bitshares_instance + +wif = "5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3" +core_unit = "TEST" + + +class Testcases(unittest.TestCase): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.bts = BitShares( + "wss://node.testnet.bitshares.eu", + nobroadcast=True, + keys={"active": wif, "owner": wif}, + ) + # from getpass import getpass + # self.bts.wallet.unlock(getpass()) + set_shared_bitshares_instance(self.bts) + self.bts.set_default_account("init0") + + def test_connect(self): + self.bts.connect() + + def test_set_default_account(self): + self.bts.set_default_account("init0") + + def test_info(self): + info = self.bts.info() + for key in ['current_witness', + 'head_block_id', + 'head_block_number', + 'id', + 'last_irreversible_block_num', + 'next_maintenance_time', + 'recently_missed_count', + 'time']: + self.assertTrue(key in info) + + def test_finalizeOps(self): + bts = self.bts + tx1 = bts.new_tx() + tx2 = bts.new_tx() + self.bts.transfer("init1", 1, core_unit, append_to=tx1) + self.bts.transfer("init1", 2, core_unit, append_to=tx2) + self.bts.transfer("init1", 3, core_unit, append_to=tx1) + tx1 = tx1.json() + tx2 = tx2.json() + ops1 = tx1["operations"] + ops2 = tx2["operations"] + self.assertEqual(len(ops1), 2) + self.assertEqual(len(ops2), 1) + + def test_transfer(self): + bts = self.bts + tx = bts.transfer( + "1.2.8", 1.33, core_unit, memo="Foobar", account="1.2.7") + self.assertEqual( + getOperationNameForId(tx["operations"][0][0]), + "transfer" + ) + op = tx["operations"][0][1] + self.assertIn("memo", op) + self.assertEqual(op["from"], "1.2.7") + self.assertEqual(op["to"], "1.2.8") + amount = Amount(op["amount"]) + self.assertEqual(float(amount), 1.33) + + def test_create_account(self): + bts = self.bts + name = ''.join(random.choice(string.ascii_lowercase) for _ in range(12)) + key1 = PrivateKey() + key2 = PrivateKey() + key3 = PrivateKey() + key4 = PrivateKey() + tx = bts.create_account( + name, + registrar="init0", # 1.2.7 + referrer="init1", # 1.2.8 + referrer_percent=33, + owner_key=format(key1.pubkey, core_unit), + active_key=format(key2.pubkey, core_unit), + memo_key=format(key3.pubkey, core_unit), + additional_owner_keys=[format(key4.pubkey, core_unit)], + additional_active_keys=[format(key4.pubkey, core_unit)], + additional_owner_accounts=["committee-account"], # 1.2.0 + additional_active_accounts=["committee-account"], + proxy_account="init0", + storekeys=False + ) + self.assertEqual( + getOperationNameForId(tx["operations"][0][0]), + "account_create" + ) + op = tx["operations"][0][1] + role = "active" + self.assertIn( + format(key4.pubkey, core_unit), + [x[0] for x in op[role]["key_auths"]]) + self.assertIn( + format(key4.pubkey, core_unit), + [x[0] for x in op[role]["key_auths"]]) + self.assertIn( + "1.2.0", + [x[0] for x in op[role]["account_auths"]]) + role = "owner" + self.assertIn( + format(key4.pubkey, core_unit), + [x[0] for x in op[role]["key_auths"]]) + self.assertIn( + format(key4.pubkey, core_unit), + [x[0] for x in op[role]["key_auths"]]) + self.assertIn( + "1.2.0", + [x[0] for x in op[role]["account_auths"]]) + self.assertEqual( + op["options"]["voting_account"], + "1.2.6") + self.assertEqual( + op["registrar"], + "1.2.6") + self.assertEqual( + op["referrer"], + "1.2.7") + self.assertEqual( + op["referrer_percent"], + 33 * 100) + + def test_weight_threshold(self): + bts = self.bts + + auth = {'account_auths': [['1.2.0', '1']], + 'extensions': [], + 'key_auths': [ + ['TEST55VCzsb47NZwWe5F3qyQKedX9iHBHMVVFSc96PDvV7wuj7W86n', 1], + ['TEST7GM9YXcsoAJAgKbqW2oVj7bnNXFNL4pk9NugqKWPmuhoEDbkDv', 1]], + 'weight_threshold': 3} # threshold fine + bts._test_weights_treshold(auth) + auth = {'account_auths': [['1.2.0', '1']], + 'extensions': [], + 'key_auths': [ + ['TEST55VCzsb47NZwWe5F3qyQKedX9iHBHMVVFSc96PDvV7wuj7W86n', 1], + ['TEST7GM9YXcsoAJAgKbqW2oVj7bnNXFNL4pk9NugqKWPmuhoEDbkDv', 1]], + 'weight_threshold': 4} # too high + + with self.assertRaises(ValueError): + bts._test_weights_treshold(auth) + + def test_allow(self): + bts = self.bts + tx = bts.allow( + "TEST55VCzsb47NZwWe5F3qyQKedX9iHBHMVVFSc96PDvV7wuj7W86n", + weight=1, + threshold=1, + permission="owner" + ) + self.assertEqual( + getOperationNameForId(tx["operations"][0][0]), + "account_update" + ) + op = tx["operations"][0][1] + self.assertIn("owner", op) + self.assertIn( + ["TEST55VCzsb47NZwWe5F3qyQKedX9iHBHMVVFSc96PDvV7wuj7W86n", '1'], + op["owner"]["key_auths"]) + self.assertEqual(op["owner"]["weight_threshold"], 1) + + def test_disallow(self): + bts = self.bts + with self.assertRaisesRegex(ValueError, ".*Changes nothing.*"): + bts.disallow( + "TEST55VCzsb47NZwWe5F3qyQKedX9iHBHMVVFSc96PDvV7wuj7W86n", + weight=1, + threshold=1, + permission="owner" + ) + with self.assertRaisesRegex(ValueError, ".*Changes nothing!.*"): + bts.disallow( + "TEST6MRyAjQq8ud7hVNYcfnVPJqcVpscN5So8BhtHuGYqET5GDW5CV", + weight=1, + threshold=1, + permission="owner" + ) + + def test_update_memo_key(self): + bts = self.bts + tx = bts.update_memo_key("TEST55VCzsb47NZwWe5F3qyQKedX9iHBHMVVFSc96PDvV7wuj7W86n") + self.assertEqual( + getOperationNameForId(tx["operations"][0][0]), + "account_update" + ) + op = tx["operations"][0][1] + self.assertEqual( + op["new_options"]["memo_key"], + "TEST55VCzsb47NZwWe5F3qyQKedX9iHBHMVVFSc96PDvV7wuj7W86n") + + def test_approvewitness(self): + bts = self.bts + tx = bts.approvewitness("init0") + self.assertEqual( + getOperationNameForId(tx["operations"][0][0]), + "account_update" + ) + op = tx["operations"][0][1] + self.assertIn( + "1:0", + op["new_options"]["votes"]) + + def test_approvecommittee(self): + bts = self.bts + tx = bts.approvecommittee("init0") + self.assertEqual( + getOperationNameForId(tx["operations"][0][0]), + "account_update" + ) + op = tx["operations"][0][1] + self.assertIn( + "0:11", + op["new_options"]["votes"]) diff --git a/tests/test_objectcache.py b/tests/test_objectcache.py new file mode 100644 index 0000000000000000000000000000000000000000..aa1a03fcc70b57e8e4497c7d0c08f7a22b9e8238 --- /dev/null +++ b/tests/test_objectcache.py @@ -0,0 +1,33 @@ +import time +import unittest +from bitshares import BitShares, exceptions +from bitshares.instance import set_shared_bitshares_instance +from bitshares.blockchainobject import ObjectCache + + +class Testcases(unittest.TestCase): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.bts = BitShares( + nobroadcast=True, + ) + set_shared_bitshares_instance(self.bts) + + def test_cache(self): + cache = ObjectCache(default_expiration=1) + self.assertEqual(str(cache), "ObjectCache(n=0, default_expiration=1)") + + # Data + cache["foo"] = "bar" + self.assertIn("foo", cache) + self.assertEqual(cache["foo"], "bar") + self.assertEqual(cache.get("foo", "New"), "bar") + + # Expiration + time.sleep(2) + self.assertNotIn("foo", cache) + + # Get + self.assertEqual(cache.get("foo", "New"), "New") diff --git a/tests/test_price.py b/tests/test_price.py index 4353a1d633f8083152afaa97b52fb75ddf062de8..808577b04517209b7bae4bec56b95eacf32f2bd8 100644 --- a/tests/test_price.py +++ b/tests/test_price.py @@ -11,7 +11,8 @@ class Testcases(unittest.TestCase): def __init__(self, *args, **kwargs): super(Testcases, self).__init__(*args, **kwargs) bitshares = BitShares( - "wss://node.bitshares.eu" + "wss://node.bitshares.eu", + nobroadcast=True, ) set_shared_bitshares_instance(bitshares) diff --git a/tests/test_proposals.py b/tests/test_proposals.py new file mode 100644 index 0000000000000000000000000000000000000000..d92b23ed26796c85368d8c093d7a65757654b679 --- /dev/null +++ b/tests/test_proposals.py @@ -0,0 +1,123 @@ +import unittest +from pprint import pprint +from bitshares import BitShares +from bitsharesbase.operationids import getOperationNameForId +from bitshares.instance import set_shared_bitshares_instance + +wif = "5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3" + + +class Testcases(unittest.TestCase): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.bts = BitShares( + "wss://node.testnet.bitshares.eu", + nobroadcast=True, + keys={"active": wif}, + ) + # from getpass import getpass + # self.bts.wallet.unlock(getpass()) + set_shared_bitshares_instance(self.bts) + self.bts.set_default_account("init0") + + def test_finalizeOps_proposal(self): + bts = self.bts + # proposal = bts.new_proposal(bts.tx()) + proposal = bts.proposal() + self.bts.transfer("init1", 1, "TEST", append_to=proposal) + tx = bts.tx().json() # default tx buffer + ops = tx["operations"] + self.assertEqual(len(ops), 1) + self.assertEqual( + getOperationNameForId(ops[0][0]), + "proposal_create") + prop = ops[0][1] + self.assertEqual(len(prop["proposed_ops"]), 1) + self.assertEqual( + getOperationNameForId(prop["proposed_ops"][0]["op"][0]), + "transfer") + + def test_finalizeOps_proposal2(self): + bts = self.bts + proposal = bts.new_proposal() + # proposal = bts.proposal() + self.bts.transfer("init1", 1, "TEST", append_to=proposal) + tx = bts.tx().json() # default tx buffer + ops = tx["operations"] + self.assertEqual(len(ops), 1) + self.assertEqual( + getOperationNameForId(ops[0][0]), + "proposal_create") + prop = ops[0][1] + self.assertEqual(len(prop["proposed_ops"]), 1) + self.assertEqual( + getOperationNameForId(prop["proposed_ops"][0]["op"][0]), + "transfer") + + def test_finalizeOps_combined_proposal(self): + bts = self.bts + parent = bts.new_tx() + proposal = bts.new_proposal(parent) + self.bts.transfer("init1", 1, "TEST", append_to=proposal) + self.bts.transfer("init1", 1, "TEST", append_to=parent) + tx = parent.json() + ops = tx["operations"] + self.assertEqual(len(ops), 2) + self.assertEqual( + getOperationNameForId(ops[0][0]), + "proposal_create") + self.assertEqual( + getOperationNameForId(ops[1][0]), + "transfer") + prop = ops[0][1] + self.assertEqual(len(prop["proposed_ops"]), 1) + self.assertEqual( + getOperationNameForId(prop["proposed_ops"][0]["op"][0]), + "transfer") + + def test_finalizeOps_changeproposer_new(self): + bts = self.bts + proposal = bts.proposal(proposer="init5") + bts.transfer("init1", 1, "TEST", append_to=proposal) + tx = bts.tx().json() + ops = tx["operations"] + self.assertEqual(len(ops), 1) + self.assertEqual( + getOperationNameForId(ops[0][0]), + "proposal_create") + prop = ops[0][1] + self.assertEqual(len(prop["proposed_ops"]), 1) + self.assertEqual(prop["fee_paying_account"], "1.2.11") + self.assertEqual( + getOperationNameForId(prop["proposed_ops"][0]["op"][0]), + "transfer") + + def test_finalizeOps_changeproposer_legacy(self): + bts = self.bts + bts.proposer = "init5" + tx = bts.transfer("init1", 1, "TEST") + ops = tx["operations"] + self.assertEqual(len(ops), 1) + self.assertEqual( + getOperationNameForId(ops[0][0]), + "proposal_create") + prop = ops[0][1] + self.assertEqual(len(prop["proposed_ops"]), 1) + self.assertEqual(prop["fee_paying_account"], "1.2.11") + self.assertEqual( + getOperationNameForId(prop["proposed_ops"][0]["op"][0]), + "transfer") + + def test_new_proposals(self): + bts = self.bts + p1 = bts.new_proposal() + p2 = bts.new_proposal() + self.assertIsNotNone(id(p1), id(p2)) + + def test_new_txs(self): + bts = self.bts + p1 = bts.new_tx() + p2 = bts.new_tx() + self.assertIsNotNone(id(p1), id(p2)) diff --git a/tests/test_transactions.py b/tests/test_transactions.py index 9d015cb5474618ef083bb09e89dc9985f0291df7..4a62a49047c2b42e72050af2739ccb58a70378c9 100644 --- a/tests/test_transactions.py +++ b/tests/test_transactions.py @@ -139,7 +139,6 @@ class Testcases(unittest.TestCase): "to": pub, "nonce": nonce, "message": encrypted_memo, - "chain": prefix } memoObj = objects.Memo(**memoStruct) self.op = operations.Transfer(**{ @@ -147,7 +146,8 @@ class Testcases(unittest.TestCase): "from": from_account_id, "to": to_account_id, "amount": amount, - "memo": memoObj + "memo": memoObj, + "prefix": prefix }) self.cm = ("f68585abf4dce7c804570100000000000000000000000140420" "f0000000000040102c0ded2bc1f1305fb0faac5e6c03ee3a192" @@ -264,7 +264,8 @@ class Testcases(unittest.TestCase): "owner_special_authority": [1, {"asset": "1.3.127", "num_top_holders": 10}] - } + }, + "prefix": "BTS" }) self.cm = ("f68585abf4dce7c804570105f26416000000000000211b03000b666f" "6f6261722d6631323401000000000202fe8cc11cc8251de6977636b5" @@ -306,7 +307,8 @@ class Testcases(unittest.TestCase): "votes": [], "extensions": [] }, - "extensions": {} + "extensions": {}, + "prefix": "BTS" }) self.cm = ("f68585abf4dce7c804570106f264160000000000000" "f010100000001d6ee0501000102fe8cc11cc8251de6" diff --git a/tests/test_txbuffers.py b/tests/test_txbuffers.py new file mode 100644 index 0000000000000000000000000000000000000000..915ecdb095b78bd11af4384ad00a305965794ffc --- /dev/null +++ b/tests/test_txbuffers.py @@ -0,0 +1,107 @@ +import unittest +from bitshares import BitShares +from bitsharesbase import operations +from bitshares.instance import set_shared_bitshares_instance + +wif = "5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3" + + +class Testcases(unittest.TestCase): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.bts = BitShares( + "wss://node.testnet.bitshares.eu", + nobroadcast=True, + keys={"active": wif} + ) + set_shared_bitshares_instance(self.bts) + self.bts.set_default_account("init0") + + def test_add_one_proposal_one_op(self): + bts = self.bts + tx1 = bts.new_tx() + proposal1 = bts.new_proposal(tx1, proposer="init0") + op = operations.Transfer(**{ + "fee": {"amount": 0, "asset_id": "1.3.0"}, + "from": "1.2.0", + "to": "1.2.0", + "amount": {"amount": 0, "asset_id": "1.3.0"}, + "prefix": "TEST" + }) + proposal1.appendOps(op) + tx = tx1.json() + self.assertEqual(tx["operations"][0][0], 22) + self.assertEqual(len(tx["operations"]), 1) + ps = tx["operations"][0][1] + self.assertEqual(len(ps["proposed_ops"]), 1) + self.assertEqual(ps["proposed_ops"][0]["op"][0], 0) + + def test_add_one_proposal_two_ops(self): + bts = self.bts + tx1 = bts.new_tx() + proposal1 = bts.new_proposal(tx1, proposer="init0") + op = operations.Transfer(**{ + "fee": {"amount": 0, "asset_id": "1.3.0"}, + "from": "1.2.0", + "to": "1.2.0", + "amount": {"amount": 0, "asset_id": "1.3.0"}, + "prefix": "TEST" + }) + proposal1.appendOps(op) + proposal1.appendOps(op) + tx = tx1.json() + self.assertEqual(tx["operations"][0][0], 22) + self.assertEqual(len(tx["operations"]), 1) + ps = tx["operations"][0][1] + self.assertEqual(len(ps["proposed_ops"]), 2) + self.assertEqual(ps["proposed_ops"][0]["op"][0], 0) + self.assertEqual(ps["proposed_ops"][1]["op"][0], 0) + + def test_have_two_proposals(self): + bts = self.bts + tx1 = bts.new_tx() + + # Proposal 1 + proposal1 = bts.new_proposal(tx1, proposer="init0") + op = operations.Transfer(**{ + "fee": {"amount": 0, "asset_id": "1.3.0"}, + "from": "1.2.0", + "to": "1.2.0", + "amount": {"amount": 0, "asset_id": "1.3.0"}, + "prefix": "TEST" + }) + for i in range(0, 3): + proposal1.appendOps(op) + + # Proposal 1 + proposal2 = bts.new_proposal(tx1, proposer="init0") + op = operations.Transfer(**{ + "fee": {"amount": 0, "asset_id": "1.3.0"}, + "from": "1.2.0", + "to": "1.2.0", + "amount": {"amount": 5555555, "asset_id": "1.3.0"}, + "prefix": "TEST" + }) + for i in range(0, 2): + proposal2.appendOps(op) + tx = tx1.json() + + self.assertEqual(len(tx["operations"]), 2) # 2 proposals + + # Test proposal 1 + prop = tx["operations"][0] + self.assertEqual(prop[0], 22) + ps = prop[1] + self.assertEqual(len(ps["proposed_ops"]), 3) + for i in range(0, 3): + self.assertEqual(ps["proposed_ops"][i]["op"][0], 0) + + # Test proposal 2 + prop = tx["operations"][1] + self.assertEqual(prop[0], 22) + ps = prop[1] + self.assertEqual(len(ps["proposed_ops"]), 2) + for i in range(0, 2): + self.assertEqual(ps["proposed_ops"][i]["op"][0], 0) diff --git a/tests/test_wallet.py b/tests/test_wallet.py new file mode 100644 index 0000000000000000000000000000000000000000..07c464022ad55a72e211d0cd0abafec94942dd0b --- /dev/null +++ b/tests/test_wallet.py @@ -0,0 +1,27 @@ +import unittest +import mock +from pprint import pprint +from bitshares import BitShares +from bitshares.account import Account +from bitshares.amount import Amount +from bitshares.asset import Asset +from bitshares.instance import set_shared_bitshares_instance +from bitsharesbase.operationids import getOperationNameForId + +wif = "5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3" + + +class Testcases(unittest.TestCase): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.bts = BitShares( + nobroadcast=True, + # We want to bundle many operations into a single transaction + bundle=True, + # Overwrite wallet to use this list of wifs only + wif=[wif] + ) + self.bts.set_default_account("init0") + set_shared_bitshares_instance(self.bts) diff --git a/tox.ini b/tox.ini index f0d196647742202822a29f52fcbe49b5aa64cb90..49ea089606c6c2afae8d551dc36b0bcf537448eb 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,7 @@ skip_missing_interpreters = true deps=-rrequirements-test.txt commands= coverage run -a setup.py test - coverage report --show-missing + coverage report --show-missing --ignore-errors coverage html -i [testenv:lint]