diff --git a/.gitignore b/.gitignore index e61d52c9..b2bfec08 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # Folders stuff experiment/ .venv/ +venv/ .idea/ .tox/ .vs/ diff --git a/docs/addresses.rst b/docs/addresses.rst index 3f52d788..1e0df57c 100644 --- a/docs/addresses.rst +++ b/docs/addresses.rst @@ -9,7 +9,7 @@ Addresses >>> from hdwallet.addresses import ADDRESSES >>> ADDRESSES.names() -['Algorand', 'Aptos', 'Avalanche', 'Cardano', 'Cosmos', 'EOS', 'Ergo', 'Ethereum', 'Filecoin', 'Harmony', 'Icon', 'Injective', 'Monero', 'MultiversX', 'Nano', 'Near', 'Neo', 'OKT-Chain', 'P2PKH', 'P2SH', 'P2TR', 'P2WPKH', 'P2WPKH-In-P2SH', 'P2WSH', 'P2WSH-In-P2SH', 'Ripple', 'Solana', 'Stellar', 'Sui', 'Tezos', 'Tron', 'XinFin', 'Zilliqa'] +['Algorand', 'Aptos', 'Avalanche', 'Cardano', 'Cosmos', 'EOS', 'Ergo', 'Ethereum', 'Filecoin', 'Harmony', 'Icon', 'Injective', 'Monero', 'MultiversX', 'Nano', 'Near', 'Neo', 'OKT-Chain', 'P2PKH', 'P2SH', 'P2TR', 'P2WPKH', 'P2WPKH-In-P2SH', 'P2WSH', 'P2WSH-In-P2SH', 'Ripple', 'Solana', 'Stellar', 'Sui', 'Tezos', 'Ton', 'Tron', 'XinFin', 'Zilliqa'] >>> ADDRESSES.classes() [, , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , ] >>> from hdwallet.addresses.p2wsh import P2WSHAddress @@ -431,6 +431,18 @@ True >>> TezosAddress.decode(address=address) '26f0dbb538759db4e8256a6f11ba88ec9c214f11' +.. autoclass:: hdwallet.addresses.ton.TonAddress + :members: + +>>> from hdwallet.addresses.ton import TonAddress +>>> TonAddress.name() +'Ton' +>>> address: str = TonAddress.encode(public_key="00d14696583ee9144878635b557d515a502b04366818dfe7765737746b4f57978d") +>>> address +'UQAdGH9zh3uTnsckECChKLGOEmHbOqBJM6P3d2GPHhAtuzS2' +>>> TonAddress.decode(address=address) +'1d187f73877b939ec7241020a128b18e1261db3aa04933a3f777618f1e102dbb' + .. autoclass:: hdwallet.addresses.tron.TronAddress :members: diff --git a/docs/cryptocurrencies.rst b/docs/cryptocurrencies.rst index a0a02930..7d6699a1 100644 --- a/docs/cryptocurrencies.rst +++ b/docs/cryptocurrencies.rst @@ -1522,6 +1522,14 @@ This library simplifies the process of generating a new HDWallet's for: - ``BIP44`` ``BIP32`` - ✅ - ``P2PKH`` ``P2SH`` + * - `Ton `_ + - TON + - 607 + - ``mainnet`` + - SLIP10-Ed25519 + - ``BIP32`` ``BIP44`` + - ❌ + - ``Ton`` * - `Tron `_ - TRX - 195 diff --git a/docs/index.rst b/docs/index.rst index e66f4c74..1bdbad10 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -51,6 +51,6 @@ Python-based library implementing a Hierarchical Deterministic (HD) Wallet gener * - Derivations - ``BIP44``, ``BIP49``, ``BIP84``, ``BIP86``, ``CIP1852``, ``Custom``, ``Electrum``, ``Monero``, ``HDW (Our own custom derivation)`` * - Addresses - - ``Algorand``, ``Aptos``, ``Avalanche``, ``Cardano``, ``Cosmos``, ``EOS``, ``Ergo``, ``Ethereum``, ``Filecoin``, ``Harmony``, ``Icon``, ``Injective``, ``Monero``, ``MultiversX``, ``Nano``, ``Near``, ``Neo``, ``OKT-Chain``, ``P2PKH``, ``P2SH``, ``P2TR``, ``P2WPKH``, ``P2WPKH-In-P2SH``, ``P2WSH``, ``P2WSH-In-P2SH``, ``Ripple``, ``Solana``, ``Stellar``, ``Sui``, ``Tezos``, ``Tron``, ``XinFin``, ``Zilliqa`` + - ``Algorand``, ``Aptos``, ``Avalanche``, ``Cardano``, ``Cosmos``, ``EOS``, ``Ergo``, ``Ethereum``, ``Filecoin``, ``Harmony``, ``Icon``, ``Injective``, ``Monero``, ``MultiversX``, ``Nano``, ``Near``, ``Neo``, ``OKT-Chain``, ``P2PKH``, ``P2SH``, ``P2TR``, ``P2WPKH``, ``P2WPKH-In-P2SH``, ``P2WSH``, ``P2WSH-In-P2SH``, ``Ripple``, ``Solana``, ``Stellar``, ``Sui``, ``Tezos``, ``Ton``,``Tron``, ``XinFin``, ``Zilliqa`` * - Others - ``BIP38``, ``Wallet Import Format``, ``Serialization`` diff --git a/hdwallet/addresses/__init__.py b/hdwallet/addresses/__init__.py index cdd2c514..c2c5da48 100644 --- a/hdwallet/addresses/__init__.py +++ b/hdwallet/addresses/__init__.py @@ -40,6 +40,7 @@ from .stellar import StellarAddress from .sui import SuiAddress from .tezos import TezosAddress +from .ton import TonAddress from .tron import TronAddress from .xinfin import XinFinAddress from .zilliqa import ZilliqaAddress @@ -120,6 +121,8 @@ class ADDRESSES: +-----------------+------------------------------------------------------------------+ | Tezos | :class:`hdwallet.addresses.tezos.TezosAddress` | +-----------------+------------------------------------------------------------------+ + | Ton | :class:`hdwallet.addresses.ton.TonAddress` | + +-----------------+------------------------------------------------------------------+ | Tron | :class:`hdwallet.addresses.tron.TronAddress` | +-----------------+------------------------------------------------------------------+ | XinFin | :class:`hdwallet.addresses.xinfin.XinFinAddress` | @@ -161,6 +164,7 @@ class ADDRESSES: StellarAddress.name(): StellarAddress, SuiAddress.name(): SuiAddress, TezosAddress.name(): TezosAddress, + TonAddress.name(): TonAddress, TronAddress.name(): TronAddress, XinFinAddress.name(): XinFinAddress, ZilliqaAddress.name(): ZilliqaAddress diff --git a/hdwallet/addresses/ton.py b/hdwallet/addresses/ton.py new file mode 100644 index 00000000..05b479fe --- /dev/null +++ b/hdwallet/addresses/ton.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 + +# Copyright © 2020-2026, Abenezer Lulseged Wube +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit + +from typing import ( + Any, Union +) + +import base64 + +from ..eccs import ( + IPublicKey, SLIP10Ed25519PublicKey, validate_and_get_public_key +) +from ..cryptocurrencies import Ton +from ..crypto import crc16_ccitt +from ..libs.ton.wallet import wallet_v4r2_address_hash, wallet_v5r1_address_hash +from ..utils import ( + bytes_to_string +) +from .iaddress import IAddress + + +class TonAddress(IAddress): + + bounceable_tag: int = Ton.PARAMS.BOUNCEABLE_TAG + non_bounceable_tag: int = Ton.PARAMS.NON_BOUNCEABLE_TAG + test_flag: int = Ton.PARAMS.TEST_FLAG + + @staticmethod + def name() -> str: + """ + Returns the name of the cryptocurrency. + + :return: The name of the cryptocurrency. + :rtype: str + """ + + return "Ton" + + @classmethod + def encode(cls, public_key: Union[bytes, str, IPublicKey], **kwargs: Any) -> str: + """ + Encodes a public key into a TON user-friendly address (Wallet V5R1 by default). + + :param public_key: The public key to encode. Can be of type bytes, str, or IPublicKey. + :type public_key: Union[bytes, str, IPublicKey] + :param kwargs: Additional arguments such as bounceable, testnet, wallet_version, + wallet_id, network_global_id, subwallet_number, and workchain. + :type kwargs: Any + + :return: The encoded TON address. + :rtype: str + """ + + public_key: IPublicKey = validate_and_get_public_key( + public_key=public_key, public_key_cls=SLIP10Ed25519PublicKey + ) + bounceable: bool = kwargs.get("bounceable", Ton.PARAMS.DEFAULT_BOUNCEABLE) + testnet: bool = kwargs.get("testnet", False) + workchain: int = kwargs.get("workchain", Ton.PARAMS.WORKCHAIN) + wallet_version: str = kwargs.get("wallet_version", Ton.PARAMS.DEFAULT_WALLET_VERSION) + public_key_bytes: bytes = public_key.raw_compressed()[1:] + + if wallet_version == "v4r2": + wallet_id: int = kwargs.get("wallet_id", Ton.PARAMS.WALLET_ID) + address_hash: bytes = wallet_v4r2_address_hash( + public_key=public_key_bytes, + workchain=workchain, + wallet_id=wallet_id + ) + elif wallet_version == "v5r1": + network_global_id: int = kwargs.get( + "network_global_id", + Ton.PARAMS.TESTNET_NETWORK_GLOBAL_ID if testnet else Ton.PARAMS.MAINNET_NETWORK_GLOBAL_ID + ) + address_hash = wallet_v5r1_address_hash( + public_key=public_key_bytes, + workchain=workchain, + network_global_id=network_global_id, + subwallet_number=kwargs.get("subwallet_number", Ton.PARAMS.SUBWALLET_NUMBER), + wallet_id=kwargs.get("wallet_id") + ) + else: + raise ValueError(f"Unsupported wallet version (got: {wallet_version})") + return cls._encode_friendly_address( + address_hash=address_hash, + workchain=workchain, + bounceable=bounceable, + testnet=testnet + ) + + @classmethod + def decode(cls, address: str, **kwargs: Any) -> str: + """ + Decodes a TON user-friendly address into its raw account hash. + + :param address: The TON address to decode. + :type address: str + :param kwargs: Additional arguments. + :type kwargs: Any + + :return: The decoded account hash in its raw form. + :rtype: str + """ + + parsed_address = cls._decode_friendly_address(address=address) + return bytes_to_string(parsed_address["hash_part"]) + + @classmethod + def _encode_friendly_address( + cls, + address_hash: bytes, + workchain: int, + bounceable: bool, + testnet: bool + ) -> str: + tag = cls.bounceable_tag if bounceable else cls.non_bounceable_tag + if testnet: + tag |= cls.test_flag + + payload = bytes([tag, workchain & 0xFF]) + address_hash + checksum = crc16_ccitt(payload) + encoded = base64.b64encode(payload + checksum).decode("utf-8") + return encoded.replace("+", "-").replace("/", "_") + + @classmethod + def _decode_friendly_address(cls, address: str) -> dict: + if len(address) != 48: + raise ValueError(f"Invalid length (expected: 48, got: {len(address)})") + + normalized_address = address.replace("-", "+").replace("_", "/") + data = base64.b64decode(normalized_address + "==") + if len(data) != 36: + raise ValueError(f"Invalid address payload length (expected: 36, got: {len(data)})") + + payload, checksum = data[:34], data[34:36] + calculated_checksum = crc16_ccitt(payload) + if checksum != calculated_checksum: + raise ValueError("Invalid address checksum") + + tag = payload[0] + is_test_only = bool(tag & cls.test_flag) + tag ^= cls.test_flag if is_test_only else 0 + if tag not in (cls.bounceable_tag, cls.non_bounceable_tag): + raise ValueError(f"Invalid address tag (got: {hex(tag)})") + + workchain = -1 if payload[1] == 0xFF else payload[1] + if workchain not in (0, -1): + raise ValueError(f"Invalid workchain (got: {workchain})") + + return { + "is_test_only": is_test_only, + "is_bounceable": tag == cls.bounceable_tag, + "workchain": workchain, + "hash_part": payload[2:34] + } diff --git a/hdwallet/crypto.py b/hdwallet/crypto.py index ac700c0d..9a127781 100644 --- a/hdwallet/crypto.py +++ b/hdwallet/crypto.py @@ -311,6 +311,35 @@ def crc32(data: Union[bytes, str]) -> bytes: ) +def crc16_ccitt(data: Union[bytes, str]) -> bytes: + """ + Calculate the CRC-16-CCITT checksum of the given data. + + :param data: The data to calculate the CRC-16-CCITT checksum for, as bytes or a string. + :type data: Union[bytes, str] + + :return: The CRC-16-CCITT checksum as a 2-byte bytes object. + :rtype: bytes + """ + + poly = 0x1021 + reg = 0 + message = encode(data) + b"\x00\x00" + + for byte in message: + mask = 0x80 + while mask > 0: + reg <<= 1 + if byte & mask: + reg += 1 + mask >>= 1 + if reg > 0xFFFF: + reg &= 0xFFFF + reg ^= poly + + return bytes([(reg >> 8) & 0xFF, reg & 0xFF]) + + def xmodem_crc(data: Union[bytes, str]) -> bytes: """ Calculate the XMODEM CRC checksum of the given data. diff --git a/hdwallet/cryptocurrencies/__init__.py b/hdwallet/cryptocurrencies/__init__.py index cc9ce945..260c7d29 100644 --- a/hdwallet/cryptocurrencies/__init__.py +++ b/hdwallet/cryptocurrencies/__init__.py @@ -199,6 +199,7 @@ from .theta import Theta from .thoughtai import ThoughtAI from .toacoin import TOACoin +from .ton import Ton from .tron import Tron from .twins import TWINS from .ultimatesecurecash import UltimateSecureCash @@ -416,6 +417,7 @@ class CRYPTOCURRENCIES: Theta.NAME: Theta, ThoughtAI.NAME: ThoughtAI, TOACoin.NAME: TOACoin, + Ton.NAME: Ton, Tron.NAME: Tron, TWINS.NAME: TWINS, UltimateSecureCash.NAME: UltimateSecureCash, diff --git a/hdwallet/cryptocurrencies/ton.py b/hdwallet/cryptocurrencies/ton.py new file mode 100644 index 00000000..53f6a944 --- /dev/null +++ b/hdwallet/cryptocurrencies/ton.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +# Copyright © 2020-2026, Abenezer Lulseged Wube +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit + +from ..slip44 import CoinTypes +from ..eccs import SLIP10Ed25519ECC +from ..consts import ( + Info, Entropies, Mnemonics, Seeds, HDs, Addresses, Networks, Params, XPrivateKeyVersions, XPublicKeyVersions +) +from .icryptocurrency import ( + ICryptocurrency, INetwork +) + + +class Mainnet(INetwork): + + NAME = "mainnet" + XPRIVATE_KEY_VERSIONS = XPrivateKeyVersions({ + "P2PKH": 0x0488ade4 + }) + XPUBLIC_KEY_VERSIONS = XPublicKeyVersions({ + "P2PKH": 0x0488b21e + }) + + +class Ton(ICryptocurrency): + + NAME = "Ton" + SYMBOL = "TON" + INFO = Info({ + "SOURCE_CODE": "https://github.com/ton-blockchain", + "WHITEPAPER": "https://docs.ton.org/learn/docs", + "WEBSITES": [ + "https://ton.org" + ] + }) + ECC = SLIP10Ed25519ECC + COIN_TYPE = CoinTypes.Ton + SUPPORT_BIP38 = False + NETWORKS = Networks({ + "MAINNET": Mainnet + }) + DEFAULT_NETWORK = NETWORKS.MAINNET + ENTROPIES = Entropies({ + "BIP39" + }) + MNEMONICS = Mnemonics({ + "BIP39" + }) + SEEDS = Seeds({ + "BIP39" + }) + HDS = HDs({ + "BIP32", "BIP44" + }) + DEFAULT_HD = HDS.BIP44 + DEFAULT_PATH = f"m/44'/{COIN_TYPE}'/0'" + ADDRESSES = Addresses({ + "TON": "Ton" + }) + DEFAULT_ADDRESS = ADDRESSES.TON + SEMANTICS = [ + "p2pkh" + ] + DEFAULT_SEMANTIC = "p2pkh" + PARAMS = Params({ + "BOUNCEABLE_TAG": 0x11, + "NON_BOUNCEABLE_TAG": 0x51, + "TEST_FLAG": 0x80, + "DEFAULT_BOUNCEABLE": False, + "WORKCHAIN": 0, + "WALLET_ID": 698983191, + "DEFAULT_WALLET_VERSION": "v5r1", + "MAINNET_NETWORK_GLOBAL_ID": -239, + "TESTNET_NETWORK_GLOBAL_ID": -3, + "SUBWALLET_NUMBER": 0 + }) diff --git a/hdwallet/hds/bip44.py b/hdwallet/hds/bip44.py index d46ba965..a9a22531 100644 --- a/hdwallet/hds/bip44.py +++ b/hdwallet/hds/bip44.py @@ -14,7 +14,7 @@ from ..addresses import P2PKHAddress from ..exceptions import DerivationError from ..derivations import ( - IDerivation, BIP44Derivation + IDerivation, BIP44Derivation, CustomDerivation ) from .bip32 import BIP32HD @@ -141,6 +141,12 @@ def from_derivation(self, derivation: IDerivation) -> "BIP44HD": :rtype: BIP44HD """ + if isinstance(derivation, CustomDerivation): + self.clean_derivation() + self._derivation = derivation + for index in self._derivation.indexes(): + self.drive(index) + return self if not isinstance(derivation, BIP44Derivation): raise DerivationError( "Invalid derivation instance", expected=BIP44Derivation, got=type(derivation) diff --git a/hdwallet/hdwallet.py b/hdwallet/hdwallet.py index 0ba19019..84a3f23c 100644 --- a/hdwallet/hdwallet.py +++ b/hdwallet/hdwallet.py @@ -40,7 +40,7 @@ get_bytes, exclude_keys ) from .derivations import ( - IDerivation, DERIVATIONS + IDerivation, DERIVATIONS, CustomDerivation ) from .addresses import ( IAddress, ADDRESSES @@ -458,6 +458,10 @@ def from_seed(self, seed: ISeed) -> "HDWallet": self._hd.from_seed( seed=seed.seed(), passphrase=self.passphrase() ) + if self._cryptocurrency.NAME == "Ton": + return self.from_derivation( + derivation=CustomDerivation(path=self._cryptocurrency.DEFAULT_PATH) + ) self._derivation = self._hd.derivation() return self @@ -1498,6 +1502,10 @@ def address(self, address: Optional[Union[str, Type[IAddress]]] = None, **kwargs public_key_type=self.public_key_type(), hrp=self._network.HRP ) + encode_kwargs = ( + exclude_keys(kwargs, {"address_type", "address_prefix"}) + if self._cryptocurrency.NAME == "Ton" else {} + ) return ADDRESSES.address(name=address).encode( public_key=self.public_key(), public_key_address_prefix=self._network.PUBLIC_KEY_ADDRESS_PREFIX, @@ -1510,7 +1518,8 @@ def address(self, address: Optional[Union[str, Type[IAddress]]] = None, **kwargs ), address_prefix=kwargs.get( "address_prefix", self._address_prefix # Tezos - ) + ), + **encode_kwargs ) def dump(self, exclude: Optional[set] = None) -> dict: diff --git a/hdwallet/libs/ton/__init__.py b/hdwallet/libs/ton/__init__.py new file mode 100644 index 00000000..53cf9f46 --- /dev/null +++ b/hdwallet/libs/ton/__init__.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 + +# Copyright © 2020-2026, Abenezer Lulseged Wube +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit + +from .wallet import wallet_v4r2_address_hash, wallet_v5r1_address_hash + +__all__ = [ + "wallet_v4r2_address_hash", + "wallet_v5r1_address_hash" +] diff --git a/hdwallet/libs/ton/cell.py b/hdwallet/libs/ton/cell.py new file mode 100644 index 00000000..c26b5876 --- /dev/null +++ b/hdwallet/libs/ton/cell.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 + +# Copyright © 2020-2026, Abenezer Lulseged Wube +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit + +import math +from hashlib import sha256 +from typing import List, Tuple + + +class BitString: + + def __init__(self, length: int): + self.array = bytearray(math.ceil(length / 8)) + self.cursor = 0 + self.length = length + + def get(self, index: int) -> int: + return int((self.array[index // 8] & (1 << (7 - (index % 8)))) > 0) + + def write_bit(self, value: int) -> None: + if value: + self.array[self.cursor // 8] |= 1 << (7 - (self.cursor % 8)) + else: + self.array[self.cursor // 8] &= ~(1 << (7 - (self.cursor % 8))) + self.cursor += 1 + + def write_bit_array(self, bits: str) -> None: + for bit in bits: + self.write_bit(int(bit)) + + def write_uint(self, number: int, bit_length: int) -> None: + if bit_length == 0: + return + if number != 0 and len(bin(number)[2:]) > bit_length: + raise ValueError(f"bitLength is too small for number, got number={number}, bitLength={bit_length}") + for index in range(bit_length, 0, -1): + mask = 2 ** (index - 1) + self.write_bit(1 if number >= mask else 0) + if number >= mask: + number -= mask + + def write_bytes(self, data: bytes) -> None: + for byte in data: + self.write_uint(byte, 8) + + def get_top_upped_array(self) -> bytes: + padding = math.ceil(self.cursor / 8) * 8 - self.cursor + if padding: + padding -= 1 + self.write_bit(1) + while padding: + padding -= 1 + self.write_bit(0) + return bytes(self.array[:math.ceil(self.cursor / 8)]) + + +class Cell: + + REACH_BOC_MAGIC_PREFIX = bytes.fromhex("B5EE9C72") + + def __init__(self) -> None: + self.bits = BitString(1023) + self.refs: List["Cell"] = [] + + def write_cell(self, other: "Cell") -> None: + for bit_index in range(other.bits.cursor): + self.bits.write_bit(other.bits.get(bit_index)) + self.refs.extend(other.refs) + + def bytes_hash(self) -> bytes: + return sha256(self.bytes_repr()).digest() + + def bytes_repr(self) -> bytes: + payload = self.get_data_with_descriptors() + for reference in self.refs: + payload += reference.get_max_depth_as_array() + for reference in self.refs: + payload += reference.bytes_hash() + return payload + + def get_data_with_descriptors(self) -> bytes: + return self.get_refs_descriptor() + self.get_bits_descriptor() + self.bits.get_top_upped_array() + + def get_bits_descriptor(self) -> bytes: + return bytes([math.ceil(self.bits.cursor / 8) + math.floor(self.bits.cursor / 8)]) + + def get_refs_descriptor(self) -> bytes: + return bytes([len(self.refs)]) + + def get_max_depth(self) -> int: + if not self.refs: + return 0 + return max(reference.get_max_depth() for reference in self.refs) + 1 + + def get_max_depth_as_array(self) -> bytes: + max_depth = self.get_max_depth() + return bytes([max_depth // 256, max_depth % 256]) + + def tree_walk(self) -> Tuple[List[Tuple[bytes, "Cell"]], dict]: + return _tree_walk(self, [], {}) + + def serialize_for_boc(self, cells_index: dict, ref_size: int) -> bytes: + payload = self.get_data_with_descriptors() + for reference in self.refs: + ref_index = cells_index[reference.bytes_hash()] + ref_hex = format(ref_index, "x") + if len(ref_hex) % 2: + ref_hex = "0" + ref_hex + payload += bytes.fromhex(ref_hex) + return payload + + def boc_serialization_size(self, cells_index: dict, ref_size: int) -> int: + return len(self.serialize_for_boc(cells_index, ref_size)) + + @staticmethod + def one_from_boc(serialized_boc: bytes) -> "Cell": + cells = deserialize_boc(serialized_boc) + if len(cells) != 1: + raise ValueError("Expected 1 root cell") + return cells[0] + + +def _tree_walk(cell: Cell, topological_order: list, index_hashmap: dict, parent_hash: bytes = None): + cell_hash = cell.bytes_hash() + if cell_hash in index_hashmap: + if parent_hash and index_hashmap[parent_hash] > index_hashmap[cell_hash]: + topological_order, index_hashmap = _move_to_end(index_hashmap, topological_order, cell_hash) + return topological_order, index_hashmap + + index_hashmap[cell_hash] = len(topological_order) + topological_order.append((cell_hash, cell)) + for sub_cell in cell.refs: + topological_order, index_hashmap = _tree_walk(sub_cell, topological_order, index_hashmap, cell_hash) + return topological_order, index_hashmap + + +def _move_to_end(index_hashmap: dict, topological_order: list, target: bytes): + target_index = index_hashmap[target] + for cell_hash in list(index_hashmap): + if index_hashmap[cell_hash] > target_index: + index_hashmap[cell_hash] -= 1 + index_hashmap[target] = len(topological_order) - 1 + data = topological_order.pop(target_index) + topological_order.append(data) + for sub_cell in data[1].refs: + topological_order, index_hashmap = _move_to_end(index_hashmap, topological_order, sub_cell.bytes_hash()) + return topological_order, index_hashmap + + +def _read_n_bytes_uint_from_array(size_bytes: int, data: bytes) -> int: + value = 0 + for byte in data[:size_bytes]: + value = (value << 8) + byte + return value + + +def _crc32c(data: bytes) -> bytes: + poly = 0x82F63B78 + crc = 0xFFFFFFFF + for byte in data: + crc ^= byte + for _ in range(8): + crc = ((crc >> 1) ^ poly) if (crc & 1) else (crc >> 1) + crc ^= 0xFFFFFFFF + return crc.to_bytes(4, "little") + + +def deserialize_boc(serialized_boc: bytes) -> List[Cell]: + input_data = serialized_boc + if len(serialized_boc) < 5: + raise ValueError("Not enough bytes for BoC header") + + if serialized_boc[:4] != Cell.REACH_BOC_MAGIC_PREFIX: + raise ValueError("Unsupported BoC magic prefix") + + flags_byte = serialized_boc[4] + has_idx = flags_byte & 128 + hash_crc32 = flags_byte & 64 + size_bytes = flags_byte % 8 + serialized_boc = serialized_boc[5:] + + if len(serialized_boc) < 1 + 5 * size_bytes: + raise ValueError("Not enough bytes for encoding cells counters") + + offset_bytes = serialized_boc[0] + serialized_boc = serialized_boc[1:] + cells_num = _read_n_bytes_uint_from_array(size_bytes, serialized_boc) + serialized_boc = serialized_boc[size_bytes:] + roots_num = _read_n_bytes_uint_from_array(size_bytes, serialized_boc) + serialized_boc = serialized_boc[size_bytes:] + _read_n_bytes_uint_from_array(size_bytes, serialized_boc) + serialized_boc = serialized_boc[size_bytes:] + tot_cells_size = _read_n_bytes_uint_from_array(offset_bytes, serialized_boc) + serialized_boc = serialized_boc[offset_bytes:] + + root_list = [] + for _ in range(roots_num): + root_list.append(_read_n_bytes_uint_from_array(size_bytes, serialized_boc)) + serialized_boc = serialized_boc[size_bytes:] + + if has_idx: + for _ in range(cells_num): + _read_n_bytes_uint_from_array(offset_bytes, serialized_boc) + serialized_boc = serialized_boc[offset_bytes:] + + cells_data = serialized_boc[:tot_cells_size] + serialized_boc = serialized_boc[tot_cells_size:] + + if hash_crc32: + if len(serialized_boc) < 4: + raise ValueError("Not enough bytes for crc32c hashsum") + if _crc32c(input_data[:-4]) != serialized_boc[:4]: + raise ValueError("Crc32c hashsum mismatch") + + cells_array = [] + for _ in range(cells_num): + cell, cells_data = _deserialize_cell_data(cells_data, size_bytes) + cells_array.append(cell) + + for cell_index in reversed(range(cells_num)): + cell = cells_array[cell_index] + for ref_index in range(len(cell.refs)): + reference = cell.refs[ref_index] + if reference < cell_index: + raise ValueError("Topological order is broken") + cell.refs[ref_index] = cells_array[reference] + + return [cells_array[root_index] for root_index in root_list] + + +def _deserialize_cell_data(cell_data: bytes, reference_index_size: int): + if len(cell_data) < 2: + raise ValueError("Not enough bytes to encode cell descriptors") + + d1, d2 = cell_data[0], cell_data[1] + cell_data = cell_data[2:] + ref_num = d1 % 8 + data_bytes_size = math.ceil(d2 / 2) + fullfilled_bytes = not (d2 % 2) + + cell = Cell() + if len(cell_data) < data_bytes_size + reference_index_size * ref_num: + raise ValueError("Not enough bytes to encode cell data") + + cell.bits.cursor = len(cell_data[:data_bytes_size]) * 8 + cell.bits.array = bytearray(cell_data[:data_bytes_size]) + if not fullfilled_bytes and cell.bits.cursor: + found_end_bit = False + for _ in range(7): + cell.bits.cursor -= 1 + if cell.bits.get(cell.bits.cursor): + found_end_bit = True + cell.bits.array[cell.bits.cursor // 8] &= ~(1 << (7 - (cell.bits.cursor % 8))) + break + if not found_end_bit: + raise ValueError("Incorrect top-upped array") + + cell_data = cell_data[data_bytes_size:] + for _ in range(ref_num): + cell.refs.append(_read_n_bytes_uint_from_array(reference_index_size, cell_data)) + cell_data = cell_data[reference_index_size:] + + return cell, cell_data diff --git a/hdwallet/libs/ton/wallet.py b/hdwallet/libs/ton/wallet.py new file mode 100644 index 00000000..fda650eb --- /dev/null +++ b/hdwallet/libs/ton/wallet.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 + +# Copyright © 2020-2026, Abenezer Lulseged Wube +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit + +from typing import Optional + +from .cell import BitString, Cell + +WALLET_V4R2_CODE_BOC = bytes.fromhex( + "B5EE9C72410214010002D4000114FF00F4A413F4BCF2C80B010201200203020148040504F8F28308D71820" + "D31FD31FD31F02F823BBF264ED44D0D31FD31FD3FFF404D15143BAF2A15151BAF2A205F901541064F910F2A3F800" + "24A4C8CB1F5240CB1F5230CBFF5210F400C9ED54F80F01D30721C0009F6C519320D74A96D307D402FB00E830E021" + "C001E30021C002E30001C0039130E30D03A4C8CB1F12CB1FCBFF1011121302E6D001D0D3032171B0925F04E022" + "D749C120925F04E002D31F218210706C7567BD22821064737472BDB0925F05E003FA403020FA4401C8CA07CBFFC9" + "D0ED44D0810140D721F404305C810108F40A6FA131B3925F07E005D33FC8258210706C7567BA923830E30D038" + "21064737472BA925F06E30D06070201200809007801FA00F40430F8276F2230500AA121BEF2E0508210706C7567" + "831EB17080185004CB0526CF1658FA0219F400CB6917CB1F5260CB3F20C98040FB0006008A5004810108F45930ED" + "44D0810140D720C801CF16F400C9ED540172B08E23821064737472831EB17080185005CB055003CF1623FA0213C" + "B6ACB1FCB3FC98040FB00925F03E20201200A0B0059BD242B6F6A2684080A06B90FA0218470D4080847A4937D" + "29910CE6903E9FF9837812801B7810148987159F31840201580C0D0011B8C97ED44D0D70B1F8003DB29DFB51342" + "0405035C87D010C00B23281F2FFF274006040423D029BE84C600201200E0F0019ADCE76A26840206B90EB85FFC00" + "019AF1DF6A26840106B90EB858FC0006ED207FA00D4D422F90005C8CA0715CBFFC9D077748018C8CB05CB0222CF" + "165005FA0214CB6B12CCCCC973FB00C84014810108F451F2A7020070810108D718FA00D33FC8542047810108F45" + "1F2A782106E6F746570748018C8CB05CB025006CF165004FA0214CB6A12CB1FCB3FC973FB0002006C810108D718" + "FA00D33F305224810108F459F2A782106473747270748018C8CB05CB025005CF165003FA0213CB6ACB1F12CB3FC9" + "73FB00000AF400C9ED54696225E5" +) +WALLET_V5R1_CODE_BOC = bytes.fromhex( + "b5ee9c7241021401000281000114ff00f4a413f4bcf2c80b01020120020d020148030402dcd020d749c120915b8f6320" + "d70b1f2082106578746ebd21821073696e74bdb0925f03e082106578746eba8eb48020d72101d074d721fa4030fa44f828" + "fa443058bd915be0ed44d0810141d721f4058307f40e6fa1319130e18040d721707fdb3ce03120d749810280b99130e070" + "e2100f020120050c020120060902016e07080019adce76a2684020eb90eb85ffc00019af1df6a2684010eb90eb858fc002" + "01480a0b0017b325fb51341c75c875c2c7e00011b262fb513435c280200019be5f0f6a2684080a0eb90fa02c0102f20e" + "011e20d70b1f82107369676ebaf2e08a7f0f01e68ef0eda2edfb218308d722028308d723208020d721d31fd31fd31" + "fed44d0d200d31f20d31fd3ffd70a000af90140ccf9109a28945f0adb31e1f2c087df02b35007b0f2d0845125baf2" + "e0855036baf2e086f823bbf2d0882292f800de01a47fc8ca00cb1f01cf16c9ed542092f80fde70db3cd81003f6eda2" + "edfb02f404216e926c218e4c0221d73930709421c700b38e2d01d72820761e436c20d749c008f2e09320d74ac002" + "f2e09320d71d06c712c2005230b0f2d089d74cd7393001a4e86c128407bbf2e093d74ac000f2e093ed55e2d20001" + "c000915be0ebd72c08142091709601d72c081c12e25210b1e30f20d74a111213009601fa4001fa44f828fa443058" + "baf2e091ed44d0810141d718f405049d7fc8ca0040048307f453f2e08b8e14038307f45bf2e08c22d70a00216e" + "01b3b0f2d090e2c85003cf1612f400c9ed54007230d72c08248e2d21f2e092d200ed44d0d2005113baf2d08f545" + "03091319c01810140d721d70a00f2e08ee2c8ca0058cf16c9ed5493f2c08de20010935bdb31e1d74cd0b4d6c35e" +) +DEFAULT_WALLET_ID = 698983191 +MAINNET_NETWORK_GLOBAL_ID = -239 +TESTNET_NETWORK_GLOBAL_ID = -3 + + +def _create_wallet_v4r2_data_cell(public_key: bytes, wallet_id: int) -> Cell: + cell = Cell() + cell.bits.write_uint(0, 32) + cell.bits.write_uint(wallet_id, 32) + cell.bits.write_bytes(public_key) + cell.bits.write_uint(0, 1) + return cell + + +def _create_state_init(code: Cell, data: Cell) -> Cell: + state_init = Cell() + state_init.bits.write_bit_array("00110") + state_init.refs.append(code) + state_init.refs.append(data) + return state_init + + +def _write_int(bits: BitString, number: int, bit_length: int) -> None: + if number < 0: + number = (1 << bit_length) + number + bits.write_uint(number, bit_length) + + +def _wallet_id_v5r1( + network_global_id: int, + workchain: int = 0, + subwallet_number: int = 0 +) -> int: + context = (1 << 31) | ((workchain & 0xFF) << 23) | (subwallet_number & 0x7FFF) + if context >= 2 ** 31: + context -= 2 ** 32 + wallet_id = network_global_id ^ context + if wallet_id >= 2 ** 31: + wallet_id -= 2 ** 32 + return wallet_id + + +def _create_wallet_v5r1_data_cell(public_key: bytes, wallet_id: int) -> Cell: + cell = Cell() + cell.bits.write_uint(1, 1) + cell.bits.write_uint(0, 32) + _write_int(cell.bits, wallet_id, 32) + cell.bits.write_bytes(public_key) + cell.bits.write_uint(0, 1) + return cell + + +def wallet_v4r2_address_hash( + public_key: bytes, + workchain: int = 0, + wallet_id: int = DEFAULT_WALLET_ID +) -> bytes: + """ + Derive the account hash for a TON Wallet V4R2 contract from an Ed25519 public key. + """ + + if len(public_key) != 32: + raise ValueError(f"Invalid public key length (expected: 32, got: {len(public_key)})") + + code = Cell.one_from_boc(WALLET_V4R2_CODE_BOC) + data = _create_wallet_v4r2_data_cell(public_key=public_key, wallet_id=wallet_id) + state_init = _create_state_init(code=code, data=data) + return state_init.bytes_hash() + + +def wallet_v5r1_address_hash( + public_key: bytes, + workchain: int = 0, + network_global_id: int = MAINNET_NETWORK_GLOBAL_ID, + subwallet_number: int = 0, + wallet_id: Optional[int] = None +) -> bytes: + """ + Derive the account hash for a TON Wallet V5R1 contract from an Ed25519 public key. + """ + + if len(public_key) != 32: + raise ValueError(f"Invalid public key length (expected: 32, got: {len(public_key)})") + + if wallet_id is None: + wallet_id = _wallet_id_v5r1( + network_global_id=network_global_id, + workchain=workchain, + subwallet_number=subwallet_number + ) + + code = Cell.one_from_boc(WALLET_V5R1_CODE_BOC) + data = _create_wallet_v5r1_data_cell(public_key=public_key, wallet_id=wallet_id) + state_init = _create_state_init(code=code, data=data) + return state_init.bytes_hash() diff --git a/hdwallet/slip44.py b/hdwallet/slip44.py index 9da0e0af..becbe05c 100644 --- a/hdwallet/slip44.py +++ b/hdwallet/slip44.py @@ -195,6 +195,7 @@ class CoinTypes: # https://github.com/satoshilabs/slips/blob/master/slip-0044.m Theta: int = 500 ThoughtAI: int = 502 TOACoin: int = 159 + Ton: int = 607 Tron: int = 195 TWINS: int = 970 UltimateSecureCash: int = 112 diff --git a/tests/data/json/addresses.json b/tests/data/json/addresses.json index 6839a545..9a43ded7 100644 --- a/tests/data/json/addresses.json +++ b/tests/data/json/addresses.json @@ -41,7 +41,12 @@ "name": "Near", "encode": "d14696583ee9144878635b557d515a502b04366818dfe7765737746b4f57978d", "decode": "d14696583ee9144878635b557d515a502b04366818dfe7765737746b4f57978d" - } + }, + "Ton": { + "name": "Ton", + "encode": "UQCZg4OctGv75Ptul9SXbSOpNTefD-Gr_K08996YRvu02udK", + "decode": "9983839cb46bfbe4fb6e97d4976d23a935379f0fe1abfcad3cf7de9846fbb4da" + } } }, "SLIP10-Secp256k1": { diff --git a/tests/data/raw/cryptocurrencies.txt b/tests/data/raw/cryptocurrencies.txt index a4ddd2f7..a720beb1 100644 --- a/tests/data/raw/cryptocurrencies.txt +++ b/tests/data/raw/cryptocurrencies.txt @@ -188,6 +188,7 @@ Tezos XTZ 1729 mainnet SLI Theta THETA 500 mainnet SLIP10-Secp256k1 Thought-AI THT 502 mainnet SLIP10-Secp256k1 TOA-Coin TOA 159 mainnet SLIP10-Secp256k1 +Ton TON 607 mainnet SLIP10-Ed25519 Tron TRX 195 mainnet SLIP10-Secp256k1 TWINS TWINS 970 mainnet, testnet SLIP10-Secp256k1 Ultimate-Secure-Cash USC 112 mainnet SLIP10-Secp256k1 diff --git a/tests/hdwallet/addresses/test_slip10_ed25519_addresses.py b/tests/hdwallet/addresses/test_slip10_ed25519_addresses.py index 5890f6df..1739db53 100644 --- a/tests/hdwallet/addresses/test_slip10_ed25519_addresses.py +++ b/tests/hdwallet/addresses/test_slip10_ed25519_addresses.py @@ -17,6 +17,7 @@ from hdwallet.addresses.stellar import StellarAddress from hdwallet.addresses.tezos import TezosAddress from hdwallet.addresses.sui import SuiAddress +from hdwallet.addresses.ton import TonAddress def test_algorand_address(data): @@ -113,3 +114,15 @@ def test_near_address(data): assert NearAddress.decode( address=data["addresses"]["SLIP10-Ed25519"]["addresses"]["Near"]["encode"] ) == data["addresses"]["SLIP10-Ed25519"]["addresses"]["Near"]["decode"] + + +def test_ton_address(data): + + assert TonAddress.name() == data["addresses"]["SLIP10-Ed25519"]["addresses"]["Ton"]["name"] + assert TonAddress.encode( + public_key=data["addresses"]["SLIP10-Ed25519"]["public-key"] + ) == data["addresses"]["SLIP10-Ed25519"]["addresses"]["Ton"]["encode"] + + assert TonAddress.decode( + address=data["addresses"]["SLIP10-Ed25519"]["addresses"]["Ton"]["encode"] + ) == data["addresses"]["SLIP10-Ed25519"]["addresses"]["Ton"]["decode"]