diff --git a/python/nekoton/__init__.py b/python/nekoton/__init__.py index 8d72c2b..ac5e40f 100644 --- a/python/nekoton/__init__.py +++ b/python/nekoton/__init__.py @@ -1,3 +1,4 @@ from .nekoton import * from . import gql +from . import contracts diff --git a/python/nekoton/contracts/__init__.py b/python/nekoton/contracts/__init__.py new file mode 100644 index 0000000..ae06fb6 --- /dev/null +++ b/python/nekoton/contracts/__init__.py @@ -0,0 +1,3 @@ +from .base import IGiver +from .giver import GiverV1 +from .ever_wallet import EverWallet diff --git a/python/nekoton/contracts/base.py b/python/nekoton/contracts/base.py new file mode 100644 index 0000000..48d26fe --- /dev/null +++ b/python/nekoton/contracts/base.py @@ -0,0 +1,10 @@ +import nekoton as _nt + + +class IGiver: + """ + Abstract tokens giver. + """ + + async def give(self, target: _nt.Address, amount: _nt.Tokens): + raise NotImplementedError("IGiver is an abstract class") diff --git a/python/nekoton/contracts/ever_wallet.py b/python/nekoton/contracts/ever_wallet.py new file mode 100644 index 0000000..a22d7aa --- /dev/null +++ b/python/nekoton/contracts/ever_wallet.py @@ -0,0 +1,123 @@ +from typing import Optional + +from ... import base as _base +import nekoton as _nt + +_wallet_abi = _nt.ContractAbi("""{ + "ABI version": 2, + "version": "2.3", + "header": ["pubkey", "time", "expire"], + "functions": [{ + "name": "sendTransaction", + "inputs": [ + {"name": "dest", "type": "address"}, + {"name": "value", "type": "uint128"}, + {"name": "bounce", "type": "bool"}, + {"name": "flags", "type": "uint8"}, + {"name": "payload", "type": "cell"} + ], + "outputs": [] + }], + "events": [] +}""") + +_send_transaction = _wallet_abi.get_function("sendTransaction") +assert _send_transaction is not None + +_wallet_code = _nt.Cell.decode( + "te6cckEBBgEA/AABFP8A9KQT9LzyyAsBAgEgAgMABNIwAubycdcBAcAA8nqDCNcY7UTQgwfXAdcLP8j4KM8WI88WyfkAA3HXAQHDAJqDB9cBURO68uBk3oBA1wGAINcBgCDXAVQWdfkQ8qj4I7vyeWa++COBBwiggQPoqFIgvLHydAIgghBM7mRsuuMPAcjL/8s/ye1UBAUAmDAC10zQ+kCDBtcBcdcBeNcB10z4AHCAEASqAhSxyMsFUAXPFlAD+gLLaSLQIc8xIddJoIQJuZgzcAHLAFjPFpcwcQHLABLM4skB+wAAPoIQFp4+EbqOEfgAApMg10qXeNcB1AL7AOjRkzLyPOI+zYS/" +) +_wallet_data_abi = [ + ("publicKey", _nt.AbiUint(256)), + ("timestamp", _nt.AbiUint(64)), +] + + +class EverWallet(_base.IGiver): + @classmethod + def compute_address( + cls, public_key: _nt.PublicKey, workchain: int = 0 + ) -> _nt.Address: + return cls.compute_state_init(public_key).compute_address(workchain) + + @staticmethod + def compute_state_init(public_key: _nt.PublicKey) -> _nt.StateInit: + data = _nt.Cell.build( + abi=_wallet_data_abi, + value={ + "publicKey": public_key, + "timestamp": 0, + }, + ) + return _nt.StateInit(_wallet_code, data) + + def __init__( + self, transport: _nt.Transport, keypair: _nt.KeyPair, workchain: int = 0 + ): + state_init = self.compute_state_init(keypair.public_key) + + self._initialized = False + self._transport = transport + self._keypair = keypair + self._state_init = state_init + self._address = state_init.compute_address(workchain) + + @property + def address(self) -> _nt.Address: + return self._address + + async def give(self, target: _nt.Address, amount: _nt.Tokens): + await self.send(dst=target, value=amount, payload=_nt.Cell(), bounce=False) + + async def send( + self, + dst: _nt.Address, + value: _nt.Tokens, + payload: _nt.Cell, + bounce: bool = False, + ) -> _nt.Transaction: + state_init = await self.__get_state_init() + + signature_id = await self._transport.get_signature_id() + + external_message = _send_transaction.encode_external_message( + self._address, + input={ + "dest": dst, + "value": value, + "bounce": bounce, + "flags": 3, + "payload": payload, + }, + public_key=self._keypair.public_key, + state_init=state_init, + ).sign(self._keypair, signature_id) + + tx = await self._transport.send_external_message(external_message) + if tx is None: + raise RuntimeError("Message expired") + return tx + + async def get_account_state(self) -> Optional[_nt.AccountState]: + return await self._transport.get_account_state(self._address) + + async def get_balance(self) -> _nt.Tokens: + state = await self.get_account_state() + if state is None: + return _nt.Tokens(0) + else: + return state.balance + + async def __get_state_init(self) -> Optional[_nt.StateInit]: + if self._initialized: + return None + + account_state = await self.get_account_state() + if ( + account_state is not None + and account_state.status == _nt.AccountStatus.Active + ): + self._initialized = True + return None + else: + return self._state_init diff --git a/python/nekoton/contracts/giver.py b/python/nekoton/contracts/giver.py new file mode 100644 index 0000000..d9b8673 --- /dev/null +++ b/python/nekoton/contracts/giver.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from ... import base as _base +import nekoton as _nt + +_giver_v1_abi = _nt.ContractAbi("""{ + "ABI version": 1, + "functions": [{ + "name": "constructor", + "inputs": [], + "outputs": [] + }, { + "name": "sendGrams", + "inputs": [ + {"name": "dest", "type": "address"}, + {"name": "amount", "type": "uint64"} + ], + "outputs": [] + }], + "events": [] +}""") + +_giver_v1_constructor = _giver_v1_abi.get_function("constructor") +_giver_v1_send_grams = _giver_v1_abi.get_function("sendGrams") +_giver_v1_tvc = "te6ccgECJQEABaMAAgE0BgEBAcACAgPPIAUDAQHeBAAD0CAAQdgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAIo/wAgwAH0pCBYkvSg4YrtU1gw9KATBwEK9KQg9KEIAgPNQBAJAgHODQoCASAMCwAHDDbMIAAdPAZIbzyvCEhcHHwCl8CgAgEgDw4AASAA1T++wFkZWNvZGVfYWRkciD6QDL6QiBvECByuiFzurHy4H0hbxFu8uB9yHTPCwIibxLPCgcibxMicrqWI28TIs4ynyGBAQAi10mhz0AyICLOMuL+/AFkZWNvZGVfYWRkcjAhydAlVUFfBdswgAgEgEhEAK6T/fYCzsrovsTC2MLcxsvwTt4htmEAApaV/fYCwsa+6OTC3ObMyuWQ5Z6ARZ4UAOOegfBRnixJnixH9ATjnoDh9ATh9AUAgZ6B8EeeFj7lnoBBkkX2Af3+AsLGvujkwtzmzMrkvsrcyL4LAAgEgGhQB4P/+/QFtYWluX2V4dGVybmFsIY5Z/vwBZ2V0X3NyY19hZGRyINAg0wAycL2OGv79AWdldF9zcmNfYWRkcjBwyMnQVRFfAtsw4CBy1yExINMAMiH6QDP+/QFnZXRfc3JjX2FkZHIxISFVMV8E2zDYMSEVAfiOdf7+AWdldF9tc2dfcHVia2V5IMcCjhb+/wFnZXRfbXNnX3B1YmtleTFwMdsw4NUgxwGOF/7/AWdldF9tc2dfcHVia2V5MnAxMdsw4CCBAgDXIdcL/yL5ASIi+RDyqP7/AWdldF9tc2dfcHVia2V5MyADXwPbMNgixwKzFgHMlCLUMTPeJCIijjj++QFzdG9yZV9zaWdvACFvjCJvjCNvjO1HIW+M7UTQ9AVvjCDtV/79AXN0b3JlX3NpZ19lbmRfBdgixwGOE/78AW1zZ19pc19lbXB0eV8G2zDgItMfNCPTPzUgFwF2joDYji/+/gFtYWluX2V4dGVybmFsMiQiVXFfCPFAAf7+AW1haW5fZXh0ZXJuYWwzXwjbMOCAfPLwXwgYAf7++wFyZXBsYXlfcHJvdHBwcO1E0CD0BDI0IIEAgNdFmiDTPzIzINM/MjKWgggbd0Ay4iIluSX4I4ED6KgkoLmwjinIJAH0ACXPCz8izws/Ic8WIMntVP78AXJlcGxheV9wcm90Mn8GXwbbMOD+/AFyZXBsYXlfcHJvdDNwBV8FGQAE2zACASAcGwAPvOP3EDmG2YQCASAeHQCJuyXMvJ+ADwINM/MPAi/vwBcHVzaHBkYzd0b2M07UTQ9AHI7UdvEgH0ACHPFiDJ7VT+/QFwdXNocGRjN3RvYzQwXwLbMIAgEgIh8BCbiJACdQIAH+/v0BY29uc3RyX3Byb3RfMHBwgggbd0DtRNAg9AQyNCCBAIDXRY4UINI/MjMg0j8yMiBx10WUgHvy8N7eyCQB9AAjzws/Is8LP3HPQSHPFiDJ7VT+/QFjb25zdHJfcHJvdF8xXwX4ADDwIf78AXB1c2hwZGM3dG9jNO1E0PQByCEARO1HbxIB9AAhzxYgye1U/v0BcHVzaHBkYzd0b2M0MF8C2zAB4tz+/QFtYWluX2ludGVybmFsIY5Z/vwBZ2V0X3NyY19hZGRyINAg0wAycL2OGv79AWdldF9zcmNfYWRkcjBwyMnQVRFfAtsw4CBy1yExINMAMiH6QDP+/QFnZXRfc3JjX2FkZHIxISFVMV8E2zDYJCFwIwHqjjj++QFzdG9yZV9zaWdvACFvjCJvjCNvjO1HIW+M7UTQ9AVvjCDtV/79AXN0b3JlX3NpZ19lbmRfBdgixwCOHCFwuo4SIoIQXH7iB1VRXwbxQAFfBtsw4F8G2zDg/v4BbWFpbl9pbnRlcm5hbDEi0x80InG6JAA2niCAI1VhXwfxQAFfB9sw4CMhVWFfB/FAAV8H" + + +class GiverV1(_base.IGiver): + @staticmethod + def compute_address(workchain: int = 0) -> _nt.Address: + return _nt.Address.from_parts( + workchain, _nt.Cell.decode(_giver_v1_tvc).repr_hash + ) + + @staticmethod + async def deploy( + transport: _nt.Transport, + workchain: int = 0, + other_giver: _base.IGiver | None = None, + ) -> GiverV1: + # Compute giver address + state_init_cell = _nt.Cell.decode(_giver_v1_tvc) + address = _nt.Address.from_parts(workchain, state_init_cell.repr_hash) + state_init = _nt.StateInit.from_cell(state_init_cell) + + # Ensure that giver account exists + initial_balance = _nt.Tokens(1) + state = await transport.get_account_state(address) + if state is None: + if other_giver is None: + raise RuntimeError("Account does not have enough balance") + + tx = await other_giver.give(address, initial_balance) + if tx is None: + raise RuntimeError("Message expired") + await transport.trace_transaction(tx).wait() + + # Deploy account + if state.status == _nt.AccountStatus.Active: + return GiverV1(transport, workchain) + elif state.status == _nt.AccountStatus.Frozen: + raise RuntimeError("Giver account is frozen") + elif ( + state.status == _nt.AccountStatus.Uninit and state.balance < initial_balance + ): + tx = await other_giver.give(address, initial_balance) + if tx is None: + raise RuntimeError("Message expired") + await transport.trace_transaction(tx).wait() + + external_message = _giver_v1_constructor.encode_external_message( + address, + input={}, + public_key=None, + state_init=state_init, + ).without_signature() + tx = await transport.send_external_message(external_message) + if tx is None: + raise RuntimeError("Message expired") + await transport.trace_transaction(tx).wait() + + return GiverV1(transport, workchain) + + def __init__(self, transport: _nt.Transport, workchain: int = 0): + self._transport = transport + self._address = GiverV1.compute_address(workchain) + + async def give(self, target: _nt.Address, amount: _nt.Tokens): + # Prepare external message + message = _giver_v1_send_grams.encode_external_message( + self._address, + input={ + "dest": target, + "amount": amount, + }, + public_key=None, + ).without_signature() + + # Send external message + tx = await self._transport.send_external_message(message) + if tx is None: + raise RuntimeError("Message expired") + + # Wait until all transactions are produced + await self._transport.trace_transaction(tx).wait() diff --git a/python/nekoton/nekoton.pyi b/python/nekoton/nekoton.pyi index d4f6e11..f79fc86 100644 --- a/python/nekoton/nekoton.pyi +++ b/python/nekoton/nekoton.pyi @@ -477,6 +477,140 @@ class EventAbi: def __ne__(self, other) -> Any: ... +class Message: + """ + Blockchain message. + """ + + @staticmethod + def from_bytes(bytes: bytes) -> Message: + """ + Decodes message from raw bytes. + + :param bytes: raw bytes with BOC. + """ + ... + + @staticmethod + def from_cell(cell: Cell) -> Message: + """ + Decodes message from the cell. + + :param cell: root message cell. + """ + ... + + @staticmethod + def decode(value: str, encoding: Optional[str] = None) -> Message: + """ + Decodes the message from the encoded BOC. + + :param value: a string with encoded BOC. + :param encoding: encoding type. `base64` (default) or `hex`. + """ + ... + + @property + def hash(self) -> bytes: + """The hash of the root message cell.""" + ... + + @property + def is_external_in(self) -> bool: + """Whether this message is `ExternalIn`.""" + ... + + @property + def is_external_out(self) -> bool: + """Whether this message is `ExternalOut`.""" + ... + + @property + def is_internal(self) -> bool: + """Whether this message is `Internal`.""" + ... + + @property + def type(self) -> MessageType: + """Message type.""" + ... + + @property + def header(self) -> MessageHeader: + """Message header""" + ... + + @property + def created_at(self) -> int: + """A unix timestamp when this message was created. (always 0 for `ExternalIn`).""" + ... + + @property + def created_lt(self) -> int: + """A logical timestamp when this message was created. (always 0 for `ExternalIn`).""" + ... + + @property + def src(self) -> Optional[Address]: + """Source address. (None for ExternalIn).""" + ... + + @property + def dst(self) -> Optional[Address]: + """Destination address. (None for `ExternalOut`).""" + ... + + @property + def value(self) -> Tokens: + """Attached amount of nano EVERs. (always 0 for non `Internal`).""" + ... + + @property + def bounced(self) -> bool: + """Whether this message was bounced.""" + ... + + @property + def body(self) -> Optional[Cell]: + """Optional message body.""" + ... + + @property + def state_init(self) -> Optional[StateInit]: + """Optional state init.""" + ... + + def encode(self, encoding: Optional[str] = None) -> str: + """ + Encodes the message into BOC. + + :param encoding: encoding type. `base64` (default) or `hex`. + """ + ... + + def to_bytes(self) -> bytes: + """Encodes message into raw bytes.""" + ... + + def build_cell(self) -> Cell: + """Encodes message into a new cell.""" + ... + + def __eq__(self, other) -> Any: ... + + def __ge__(self, other) -> Any: ... + + def __gt__(self, other) -> Any: ... + + def __hash__(self) -> Any: ... + + def __le__(self, other) -> Any: ... + + def __lt__(self, other) -> Any: ... + + def __ne__(self, other) -> Any: ... + + class SignedExternalMessage(Message): """ External message with an additional expiration param. @@ -1350,140 +1484,6 @@ class AccountStatusChange: def __ne__(self, other) -> Any: ... -class Message: - """ - Blockchain message. - """ - - @staticmethod - def from_bytes(bytes: bytes) -> Message: - """ - Decodes message from raw bytes. - - :param bytes: raw bytes with BOC. - """ - ... - - @staticmethod - def from_cell(cell: Cell) -> Message: - """ - Decodes message from the cell. - - :param cell: root message cell. - """ - ... - - @staticmethod - def decode(value: str, encoding: Optional[str] = None) -> Message: - """ - Decodes the message from the encoded BOC. - - :param value: a string with encoded BOC. - :param encoding: encoding type. `base64` (default) or `hex`. - """ - ... - - @property - def hash(self) -> bytes: - """The hash of the root message cell.""" - ... - - @property - def is_external_in(self) -> bool: - """Whether this message is `ExternalIn`.""" - ... - - @property - def is_external_out(self) -> bool: - """Whether this message is `ExternalOut`.""" - ... - - @property - def is_internal(self) -> bool: - """Whether this message is `Internal`.""" - ... - - @property - def type(self) -> MessageType: - """Message type.""" - ... - - @property - def header(self) -> MessageHeader: - """Message header""" - ... - - @property - def created_at(self) -> int: - """A unix timestamp when this message was created. (always 0 for `ExternalIn`).""" - ... - - @property - def created_lt(self) -> int: - """A logical timestamp when this message was created. (always 0 for `ExternalIn`).""" - ... - - @property - def src(self) -> Optional[Address]: - """Source address. (None for ExternalIn).""" - ... - - @property - def dst(self) -> Optional[Address]: - """Destination address. (None for `ExternalOut`).""" - ... - - @property - def value(self) -> Tokens: - """Attached amount of nano EVERs. (always 0 for non `Internal`).""" - ... - - @property - def bounced(self) -> bool: - """Whether this message was bounced.""" - ... - - @property - def body(self) -> Optional[Cell]: - """Optional message body.""" - ... - - @property - def state_init(self) -> Optional[StateInit]: - """Optional state init.""" - ... - - def encode(self, encoding: Optional[str] = None) -> str: - """ - Encodes the message into BOC. - - :param encoding: encoding type. `base64` (default) or `hex`. - """ - ... - - def to_bytes(self) -> bytes: - """Encodes message into raw bytes.""" - ... - - def build_cell(self) -> Cell: - """Encodes message into a new cell.""" - ... - - def __eq__(self, other) -> Any: ... - - def __ge__(self, other) -> Any: ... - - def __gt__(self, other) -> Any: ... - - def __hash__(self) -> Any: ... - - def __le__(self, other) -> Any: ... - - def __lt__(self, other) -> Any: ... - - def __ne__(self, other) -> Any: ... - - class MessageHeader: """Base message header."""