diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e6e82bb..95953d3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,6 +1,6 @@ name: Build and deploy documentation permissions: - contents: read + contents: write on: push: @@ -46,11 +46,12 @@ jobs: sphinx-build -M latexpdf docs docs/_build cp docs/_build/latex/cryptnox-cli.pdf docs/_build/html/cryptnox-cli.pdf - # 5. Deploy HTML documentation to GitHub Pages + # 5. Deploy HTML documentation to GitHub Pages (only on push to main) - name: Deploy to GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: peaceiris/actions-gh-pages@v4 with: - github_token: ${{ secrets.DOCS_DEPLOY_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: docs/_build/html publish_branch: gh-pages allow_empty_commit: false diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 52733c7..604c19e 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -11,6 +11,7 @@ on: permissions: contents: read + actions: read # Required by codeql-action/upload-sarif to read workflow run info security-events: write # Required for SARIF upload pull-requests: write # For PR comments diff --git a/.gitignore b/.gitignore index 29331b5..82416fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ __pycache__ +*.egg-info/ *.pem build dist diff --git a/Pipfile b/Pipfile index a3e470f..acdefdf 100644 --- a/Pipfile +++ b/Pipfile @@ -17,10 +17,11 @@ base58 = "*" boto3 = "*" ecdsa = ">=0.19.2" colander = "*" -cryptnox-sdk-py = ">=1.0.2" +cryptnox-sdk-py = ">=1.0.5" lazy-import = "*" pytz = "*" requests = ">=2.33.0" +solders = ">=0.27.0" tabulate = "*" stdiomask = "*" web3 = ">=7.15.0" diff --git a/cryptnox_cli/command/card/info.py b/cryptnox_cli/command/card/info.py index 077de78..877a8f0 100644 --- a/cryptnox_cli/command/card/info.py +++ b/cryptnox_cli/command/card/info.py @@ -3,6 +3,7 @@ Module containing command for getting information about the card """ from concurrent.futures import ThreadPoolExecutor +from decimal import Decimal from typing import List, Dict import cryptnox_sdk_py @@ -23,12 +24,14 @@ from ...wallet import eth from ...wallet.btc import BTCwallet, BlkHubApi from ...wallet import xrp as xrp_wallet + from ...wallet import solana as solana_wallet except ImportError: import enums from config import get_configuration from wallet import eth from wallet.btc import BTCwallet, BlkHubApi from wallet import xrp as xrp_wallet + from wallet import solana as solana_wallet __all__ = ['Cards'] @@ -48,22 +51,26 @@ def execute(card) -> int: btc_pubkey = Info._fetch_btc_pubkey(card) eth_pubkey = Info._fetch_eth_pubkey(card) xrp_pubkey = Info._fetch_xrp_pubkey(card) + solana_pubkey = Info._fetch_solana_pubkey(card) # BNB uses the same derivation path and key as ETH # Extract configs before spawning threads (card.user_data access is not thread-safe) config = get_configuration(card) btc_config = config["btc"] eth_config = config["eth"] + solana_config = config["solana"] # Phase 2: query all network balances in parallel - with ThreadPoolExecutor(max_workers=4) as executor: + with ThreadPoolExecutor(max_workers=5) as executor: f_btc = executor.submit(Info._get_btc_info, btc_config, btc_pubkey) f_eth = executor.submit(Info._get_eth_info, eth_config, eth_pubkey) f_xrp = executor.submit(Info._get_xrp_info, xrp_pubkey) f_bnb = executor.submit(Info._get_bnb_info, eth_pubkey) + f_sol = executor.submit(Info._get_solana_info, solana_config, solana_pubkey) eth_info = f_eth.result() - Info._print_info_table([f_btc.result(), eth_info, f_xrp.result(), f_bnb.result()]) + Info._print_info_table([f_btc.result(), eth_info, f_xrp.result(), f_bnb.result(), + f_sol.result()]) if not config["eth"]["api_key"] and config["eth"]["endpoint"] == "infura": print("\nTo use the Ethereum network with Infura. Go to https://infura.io. " @@ -110,6 +117,24 @@ def _fetch_xrp_pubkey(card): except Exception: return None + @staticmethod + def _fetch_solana_pubkey(card): + # None -> pre-2.0 applet (SDK's version guard rejects Ed25519). + # "" -> any other derivation/communication error. + # These are distinguished so the info table doesn't mislabel a network + # blip as "Requires applet v2.0". + try: + return card.get_public_key( + cryptnox_sdk_py.Derivation.DERIVE, + key_type=cryptnox_sdk_py.KeyType.ED25519, + path=solana_wallet.PATH, + compressed=False + ) + except cryptnox_sdk_py.exceptions.AppletVersionException: + return None + except Exception: + return "" + @staticmethod def _get_btc_info(config, pubkey) -> dict: if pubkey is None: @@ -182,6 +207,37 @@ def _get_xrp_info(pubkey) -> dict: return tabulate_data + @staticmethod + def _get_solana_info(config, pubkey) -> dict: + network = config.get("network", "mainnet").lower() + tabulate_data = {"name": "SOL", "network": network, "balance": "--"} + + # None -> pre-2.0 applet (no Ed25519); "" -> derivation/communication error. + if pubkey is None: + tabulate_data["address"] = "Requires applet v2.0" + return tabulate_data + if not pubkey: + tabulate_data["address"] = "Error" + return tabulate_data + + try: + tabulate_data["address"] = solana_wallet.address(pubkey) + except Exception as error: + print(f"There's an issue in retrieving Solana address: {error}") + tabulate_data["address"] = "Error" + return tabulate_data + + try: + api = solana_wallet.SolanaApi(network, config.get("endpoint", "")) + balance_lamports = api.get_balance(tabulate_data["address"]) + balance_sol = Decimal(balance_lamports) / solana_wallet.LAMPORTS_PER_SOL + tabulate_data["balance"] = f"{balance_sol:f} SOL" + except Exception as error: + print(f"There's an issue in retrieving Solana balance: {error}") + tabulate_data["balance"] = "Network issue" + + return tabulate_data + @staticmethod def _get_bnb_info(public_key) -> dict: # BNB on Binance Smart Chain (BSC) is EVM-compatible: same secp256k1 diff --git a/cryptnox_cli/command/factory.py b/cryptnox_cli/command/factory.py index ea21bd4..44304b0 100644 --- a/cryptnox_cli/command/factory.py +++ b/cryptnox_cli/command/factory.py @@ -25,7 +25,7 @@ def command(data: Namespace, cards: CardManager = None) -> Command: # Dynamically import all command modules to register them with Command.__subclasses__() command_modules = [ 'btc', 'card_configuration', 'change_pin', 'change_puk', 'config', - 'eth', 'history', 'info', 'initialize', 'seed', 'cards', 'server', + 'eth', 'history', 'info', 'initialize', 'seed', 'solana', 'cards', 'server', 'reset', 'unlock_pin', 'user_key', 'transfer', 'get_xpub', 'get_clearpubkey', 'decrypt', 'manufacturer_certificate' ] diff --git a/cryptnox_cli/command/helper/config.py b/cryptnox_cli/command/helper/config.py index 439b037..d33de22 100644 --- a/cryptnox_cli/command/helper/config.py +++ b/cryptnox_cli/command/helper/config.py @@ -16,6 +16,7 @@ from wallet.validators import ValidationError from wallet.btc import BlkHubApi, BtcValidator from wallet.eth import EthValidator + from wallet.solana import SolanaValidator except ImportError: from ...config import ( get_configuration, @@ -24,8 +25,9 @@ from ...wallet.validators import ValidationError from ...wallet.btc import BlkHubApi, BtcValidator from ...wallet.eth import EthValidator + from ...wallet.solana import SolanaValidator - __all__ = ["BtcValidator", "EthValidator"] + __all__ = ["BtcValidator", "EthValidator", "SolanaValidator"] def add_config_sub_parser(sub_parser, crypto_currency: str) -> None: @@ -122,11 +124,14 @@ def print_key_config(card: cryptnox_sdk_py.Card, section: str, key: str) -> int: """ config = get_configuration(card) try: - old_key = key - key = "network" if key == "endpoint" and section != "eosio" else key + # btc/eth expose "endpoint" as a read-only value derived from "network". + # eosio and solana store a real, editable "endpoint" field, so don't + # remap their "endpoint" key to "network" (that printed a blank line). + _derived_endpoint = key == "endpoint" and section not in ("eosio", "solana") + key = "network" if _derived_endpoint else key value = config[section][key] endpoint = find_endpoint(section, key, value, " - YOU CAN'T EDIT THIS") - if old_key == "endpoint" and section != "eosio": + if _derived_endpoint: print(endpoint) else: print(f"{key}: {value}") @@ -148,6 +153,7 @@ def find_endpoint(section: str, key: str, value: str, append: str = "") -> str: _VALIDATORS = { "btc": BtcValidator, "eth": EthValidator, + "solana": SolanaValidator, } diff --git a/cryptnox_cli/command/helper/helper_methods.py b/cryptnox_cli/command/helper/helper_methods.py index cd23830..ff1caaa 100644 --- a/cryptnox_cli/command/helper/helper_methods.py +++ b/cryptnox_cli/command/helper/helper_methods.py @@ -61,7 +61,7 @@ def exception(self): def sign(card: cryptnox_sdk_py.Card, message: bytes, derivation: cryptnox_sdk_py.Derivation = cryptnox_sdk_py.Derivation.CURRENT_KEY, key_type: cryptnox_sdk_py.KeyType = cryptnox_sdk_py.KeyType.K1, path: str = "", - filter_eos: bool = False, pin_code: str = "") -> bytes: + pin_code: str = "") -> bytes: """ Open the card with a user key or PIN code and sign the given message in the given card @@ -70,7 +70,6 @@ def sign(card: cryptnox_sdk_py.Card, message: bytes, :param cryptnox_sdk_py.Derivation derivation: Derivation to use when signing :param cryptnox_sdk_py.KeyType key_type: Key type to use when signing :param str path: Path to use for signature generation - :param bool filter_eos: Filter signature to be compatible with eos requirements :param str pin_code: If PIN code is given use it instead of asking for it :return: Signature of the message generated in the card @@ -79,12 +78,12 @@ def sign(card: cryptnox_sdk_py.Card, message: bytes, signature = None if user_keys.authenticate(card, message): - signature = card.sign(message, derivation, key_type, path, filter_eos=filter_eos) + signature = card.sign(message, derivation, key_type, path) if not signature: if not pin_code: pin_code = security.check_pin_code(card) - signature = card.sign(message, derivation, key_type, path, pin_code, filter_eos) + signature = card.sign(message, derivation, key_type, path, pin_code) if not signature: raise ValueError("Error in getting the signature") diff --git a/cryptnox_cli/command/options/options.py b/cryptnox_cli/command/options/options.py index fbbd5c8..0b0a6fc 100644 --- a/cryptnox_cli/command/options/options.py +++ b/cryptnox_cli/command/options/options.py @@ -11,6 +11,7 @@ import argparse from . import eth +from . import solana from .common import ( add_config_sub_parser, add_pin_option @@ -39,6 +40,7 @@ def add(parser, interactive: bool = False): _btc_options(subparsers, interactive) eth.options(subparsers, interactive) + solana.options(subparsers, interactive) _transfer(subparsers, interactive) _info_options(subparsers, interactive) _history_options(subparsers, interactive) diff --git a/cryptnox_cli/command/options/solana.py b/cryptnox_cli/command/options/solana.py new file mode 100644 index 0000000..dff8c99 --- /dev/null +++ b/cryptnox_cli/command/options/solana.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +""" +Module for Solana-specific command-line argument parsing and validation, +including the send action, recipient address validation and network selection. +""" + +from decimal import Decimal, InvalidOperation + +import argparse +import base58 + +from .common import ( + add_config_sub_parser, + add_pin_option +) + +try: + import enums +except ImportError: + from ... import enums + + +# Sane upper bound: ~600M SOL exist in total; 1e9 SOL is well above any real +# transfer and keeps lamports (amount * 1e9) inside solders' u64 field. +_MAX_SOL = Decimal(10) ** 9 + + +def _validate_decimal(value: str) -> Decimal: + try: + amount = Decimal(value) + except InvalidOperation: + raise argparse.ArgumentTypeError( + f"Invalid amount: '{value}'. Please provide a valid number" + ) + if not amount.is_finite(): + raise argparse.ArgumentTypeError("Amount must be a finite number") + if amount <= 0: + raise argparse.ArgumentTypeError("Amount must be greater than 0") + if amount > _MAX_SOL: + raise argparse.ArgumentTypeError(f"Amount must not exceed {_MAX_SOL:f} SOL") + return amount + + +def _network_choices(): + return [e.name.lower() for e in enums.SolanaNetwork] + + +def _validate(address: str) -> str: + try: + decoded = base58.b58decode(address) + except ValueError: + raise argparse.ArgumentTypeError("Not a valid Solana address") + + if len(decoded) != 32: + raise argparse.ArgumentTypeError("Not a valid Solana address") + + return address + + +def _add_send(subparsers): + sub_parser = subparsers.add_parser("send", help="Simple command to send native SOL") + sub_parser.add_argument("address", type=_validate, help="Address where to send funds") + sub_parser.add_argument("amount", type=_validate_decimal, help="Amount to send") + sub_parser.add_argument("-n", "--network", choices=_network_choices(), + help="Network to use for transaction") + + +def options(subparsers, pin_option: bool): + solana_sub_parser = subparsers.add_parser(enums.Command.SOLANA.value, + help="Solana subcommands") + + if pin_option: + add_pin_option(solana_sub_parser) + + action_sub_parser = solana_sub_parser.add_subparsers(dest="solana_action", required=True) + + _add_send(action_sub_parser) + add_config_sub_parser(action_sub_parser, "Solana") diff --git a/cryptnox_cli/command/solana.py b/cryptnox_cli/command/solana.py new file mode 100644 index 0000000..7d69d70 --- /dev/null +++ b/cryptnox_cli/command/solana.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +""" +Module containing command for sending native SOL on the Solana network. + +Solana uses the Ed25519 (EdDSA) curve, available on Cryptnox applet v2.0+. The +card derives the Ed25519 key, signs the raw transaction message bytes (Ed25519 +is pure - the card hashes internally), and the resulting 64-byte signature is +assembled into a broadcastable transaction. +""" +from decimal import Decimal + +import cryptnox_sdk_py +import requests +from tabulate import tabulate + +from .command import Command +from .helper.config import create_config_method +from .helper.helper_methods import sign + +try: + import enums + from config import get_configuration + from wallet import solana as wallet +except ImportError: + from .. import enums + from ..config import get_configuration + from ..wallet import solana as wallet + + +class Solana(Command): + """ + Command for sending payment on the Solana network + """ + _name = enums.Command.SOLANA.value + + def _execute(self, card) -> int: + self._check(card) + + try: + if self.data.solana_action == "send": + return self._send(card) + if self.data.solana_action == "config": + return create_config_method(card, self.data.key, self.data.value, "solana") + except requests.HTTPError as error: + print(f"There was an issue in communication: {error}") + return -1 + except requests.RequestException as error: + print(f"There was an issue in communication: {error}") + return -1 + except ValueError as error: + print(f"Solana network error: {error}") + return -1 + + return 0 + + def _send(self, card) -> int: + config = get_configuration(card)["solana"] + + try: + derivation = cryptnox_sdk_py.Derivation[config["derivation"]] + except KeyError: + print("Derivation is invalid") + return 1 + + # An explicit --network must win over a configured endpoint. Otherwise a + # stored endpoint (typically mainnet) silently overrides --network devnet + # and real funds go to the wrong cluster. + explicit_network = getattr(self.data, "network", None) + network = explicit_network or config["network"] + endpoint = "" if explicit_network else config["endpoint"] + api = wallet.SolanaApi(network, endpoint) + + path = "" if derivation == cryptnox_sdk_py.Derivation.CURRENT_KEY else wallet.PATH + public_key = card.get_public_key(derivation, key_type=cryptnox_sdk_py.KeyType.ED25519, + path=path, compressed=False) + from_address = wallet.address(public_key) + + lamports = int(self.data.amount * wallet.LAMPORTS_PER_SOL) + + balance_lamports = api.get_balance(from_address) + if balance_lamports < lamports + wallet.BASE_FEE_LAMPORTS: + print("Not enough funds for the transaction") + return -2 + + balance_sol = Decimal(balance_lamports) / wallet.LAMPORTS_PER_SOL + + # Confirm BEFORE signing: declining must not leave a valid signed + # transaction, and signing last keeps the blockhash as fresh as possible. + if not Solana._confirm(from_address, self.data.address, balance_sol, self.data.amount): + print("Canceled by the user.") + return -1 + + blockhash = api.get_latest_blockhash() + message = wallet.build_transfer_message(public_key, self.data.address, lamports, blockhash) + message_bytes = bytes(message) + + print("\nSigning with the Cryptnox") + signature = sign(card, message_bytes, derivation, + key_type=cryptnox_sdk_py.KeyType.ED25519, path=path) + if not signature: + print("Error in getting signature") + return -1 + + transaction = wallet.assemble_transaction(message, signature) + tx_signature = api.send_transaction(transaction) + + print(f"\nTransaction id: {tx_signature}\n" + f"Balance might take some time to be refreshed.") + + return 0 + + @staticmethod + def _confirm(from_address: str, to_address: str, balance: Decimal, amount: Decimal) -> bool: + fee = Decimal(wallet.BASE_FEE_LAMPORTS) / wallet.LAMPORTS_PER_SOL + + def sol(value) -> str: + # Fixed-point SOL amount; avoids Decimal/float exponent notation (e.g. 5E-6) + return f"{Decimal(str(value)):f}" + + tabulate_table = [ + ["BALANCE:", sol(balance), "SOL", "ON", "ACCOUNT:", f"{from_address}"], + ["TRANSACTION:", sol(amount), "SOL", "TO", "ACCOUNT:", f"{to_address}"], + ["MAX FEE:", sol(fee)], + ["MAX TOTAL:", sol(amount + fee)], + ] + + print("\n\n--- Transaction Ready --- \n") + # disable_numparse: keep the pre-formatted fixed-point strings as-is; + # otherwise tabulate re-parses them and renders e.g. 0.000005 as "5e-06". + print(tabulate(tabulate_table, tablefmt='plain', disable_numparse=True), "\n") + conf = input("Confirm ? [y/N] > ") + + return conf.lower() == "y" diff --git a/cryptnox_cli/config.py b/cryptnox_cli/config.py index dadce5a..0dc122d 100644 --- a/cryptnox_cli/config.py +++ b/cryptnox_cli/config.py @@ -40,6 +40,11 @@ def get_default_configuration() -> Dict: "endpoint": "publicnode", "network": "mainnet", }, + "solana": { + "derivation": "DERIVE", + "network": "mainnet", + "endpoint": "", + }, "hidden": { "eth": { "contract": {} diff --git a/cryptnox_cli/enums.py b/cryptnox_cli/enums.py index 183f35d..6803af0 100644 --- a/cryptnox_cli/enums.py +++ b/cryptnox_cli/enums.py @@ -17,6 +17,15 @@ class EthNetwork(Enum): SEPOLIA = 11155111 +class SolanaNetwork(Enum): + """ + Class defining possible Solana networks + """ + MAINNET = 1 + DEVNET = 2 + TESTNET = 3 + + class Command(Enum): BTC = "btc" CARD_CONFIGURATION = "card_conf" @@ -31,6 +40,7 @@ class Command(Enum): SERVER = "server" RESET = "reset" SEED = "seed" + SOLANA = "solana" UNLOCK_PIN = "unlock_pin" USER_KEY = "user_key" TRANSFER = "transfer" diff --git a/cryptnox_cli/wallet/btc.py b/cryptnox_cli/wallet/btc.py index 11e57ee..07c85bb 100644 --- a/cryptnox_cli/wallet/btc.py +++ b/cryptnox_cli/wallet/btc.py @@ -185,7 +185,9 @@ def get_api(network: str) -> str: :rtype: str """ if network.lower() == "mainnet": - return "https://blkhub.net/api/" + # ponytail: blkhub.net went dark; Blockstream's Esplora is the same + # API shape (already used for testnet + allow-listed). Swap host if it dies too. + return "https://blockstream.info/api/" if network.lower() == "testnet": return "https://blockstream.info/testnet/api/" if network.lower() == "testnet4": diff --git a/cryptnox_cli/wallet/solana.py b/cryptnox_cli/wallet/solana.py new file mode 100644 index 0000000..adda53f --- /dev/null +++ b/cryptnox_cli/wallet/solana.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +""" +Solana (Ed25519) address derivation, balance lookup and native-SOL transaction +building. + +Unlike Bitcoin/Ethereum/XRP, a Solana address is **not** a hash of the public +key: it is simply the base58 encoding of the raw 32-byte Ed25519 public key. + +Transaction building, message serialization and final transaction assembly are +delegated to ``solders`` (the Rust Solana SDK Python bindings). The card signs +the *raw transaction message bytes* (Ed25519 is pure, the card hashes +internally), and the resulting 64-byte signature is grafted onto the message to +produce the broadcastable transaction. +""" +import base64 + +import base58 +import requests +from solders.hash import Hash +from solders.message import Message +from solders.pubkey import Pubkey +from solders.signature import Signature +from solders.system_program import TransferParams, transfer +from solders.transaction import Transaction + +from cryptnox_sdk_py import Derivation + +from . import validators + +try: + import enums +except ImportError: + from .. import enums + +# Solana derivation path used by Phantom and most wallets (all-hardened SLIP-0010) +PATH = "m/44'/501'/0'/0'" + +# 1 SOL = 1 000 000 000 lamports +LAMPORTS_PER_SOL = 1_000_000_000 + +# Flat per-signature base fee. A native transfer carries a single signature, so +# this is the worst-case network fee for the balance check. +BASE_FEE_LAMPORTS = 5_000 + +MAINNET = "https://api.mainnet-beta.solana.com" +DEVNET = "https://api.devnet.solana.com" +TESTNET = "https://api.testnet.solana.com" + +_NETWORK_RPC = { + "mainnet": MAINNET, + "devnet": DEVNET, + "testnet": TESTNET, +} + + +def rpc_url(network: str, endpoint: str = "") -> str: + """ + Resolve the JSON-RPC URL to use. + + A non-empty ``endpoint`` override always wins; otherwise the URL is derived + from the network name, defaulting to mainnet for unknown values. + + :param str network: Network name (mainnet/devnet/testnet) + :param str endpoint: Optional explicit RPC URL override + :return: RPC URL to use + :rtype: str + """ + if endpoint: + return endpoint + return _NETWORK_RPC.get((network or "mainnet").lower(), MAINNET) + + +def address(public_key_hex: str) -> str: + """ + Derive a Solana address from a raw Ed25519 public key. + + A Solana address is the base58 of the raw 32-byte key - there is no hashing. + + :param str public_key_hex: Raw 32-byte Ed25519 public key as hex (64 chars) + :return: Base58 Solana address + :rtype: str + """ + return base58.b58encode(bytes.fromhex(public_key_hex)).decode() + + +def build_transfer_message(from_public_key_hex: str, to_address: str, lamports: int, + recent_blockhash: str) -> Message: + """ + Build a System-Program transfer message with the sender as fee-payer. + + :param str from_public_key_hex: Sender raw Ed25519 public key as hex + :param str to_address: Recipient base58 address + :param int lamports: Amount to transfer in lamports + :param str recent_blockhash: Recent blockhash as a base58 string + :return: The (unsigned) message; ``bytes(message)`` are what the card signs + :rtype: solders.message.Message + """ + from_pubkey = Pubkey.from_bytes(bytes.fromhex(from_public_key_hex)) + to_pubkey = Pubkey.from_string(to_address) + instruction = transfer(TransferParams(from_pubkey=from_pubkey, to_pubkey=to_pubkey, + lamports=lamports)) + blockhash = Hash.from_string(recent_blockhash) + return Message.new_with_blockhash([instruction], from_pubkey, blockhash) + + +def assemble_transaction(message: Message, signature: bytes) -> Transaction: + """ + Graft a card-produced 64-byte signature onto a message to build the final + broadcastable transaction. + + :param solders.message.Message message: Message that was signed + :param bytes signature: Raw 64-byte Ed25519 signature from the card + :return: Fully signed transaction + :rtype: solders.transaction.Transaction + """ + return Transaction.populate(message, [Signature.from_bytes(signature)]) + + +class SolanaApi: + """ + Thin JSON-RPC client for the Solana network (balance, blockhash, broadcast). + """ + + def __init__(self, network: str = "mainnet", endpoint: str = ""): + self.url = rpc_url(network, endpoint) + + def _rpc(self, method: str, params: list): + payload = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params} + response = requests.post(self.url, json=payload, timeout=20) + response.raise_for_status() + data = response.json() + if "error" in data: + raise ValueError(data["error"].get("message", "Solana RPC error")) + return data["result"] + + def get_balance(self, sol_address: str) -> int: + """ + Return the balance of *sol_address* in lamports. + + Kept as an integer end-to-end; converting to SOL (float) and back would + risk off-by-one errors in the funds check. + """ + result = self._rpc("getBalance", [sol_address]) + return result["value"] + + def get_latest_blockhash(self) -> str: + """ + Return a recent blockhash as a base58 string. + + Uses ``confirmed`` (not ``finalized``): a finalized blockhash lags ~32 + slots, so it has already burned part of its validity window and can + expire during a slow confirmation. + """ + result = self._rpc("getLatestBlockhash", [{"commitment": "confirmed"}]) + return result["value"]["blockhash"] + + def send_transaction(self, transaction: Transaction) -> str: + """ + Broadcast a signed transaction (base64-encoded) and return its signature. + """ + encoded = base64.b64encode(bytes(transaction)).decode() + return self._rpc("sendTransaction", [encoded, {"encoding": "base64"}]) + + +class SolanaValidator: + """ + Class defining Solana configuration validators + """ + derivation = validators.EnumValidator(Derivation) + network = validators.EnumValidator(enums.SolanaNetwork) + endpoint = validators.AnyValidator() + + def __init__(self, derivation: str = "DERIVE", network: str = "mainnet", + endpoint: str = ""): + self.derivation = derivation + self.network = network + self.endpoint = endpoint + + def validate(self): + # All per-field validation happens on assignment via the descriptors. + return None diff --git a/requirements.txt b/requirements.txt index 815dd6d..63ec026 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,12 +13,12 @@ cffi==2.0.0; platform_python_implementation != 'PyPy' charset-normalizer==3.4.0; python_full_version >= '3.7.0' ckzg==2.0.1 colander==2.0; python_version >= '3.7' -cryptnox-sdk-py==1.0.4; python_version >= '3.11' and python_version <= '3.14' +cryptnox-sdk-py>=1.0.5; python_version >= '3.11' and python_version <= '3.14' cryptography==48.0.1; python_version >= '3.7' cytoolz==1.0.0; implementation_name == 'cpython' ecdsa==0.19.2; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' eth-abi==5.1.0; python_version >= '3.8' and python_version < '4' -eth-account==0.13.4; python_version >= '3.8' and python_version < '4' +eth-account==0.13.7; python_version >= '3.8' and python_version < '4' eth-hash[pycryptodome]==0.7.0; python_version >= '3.8' and python_version < '4' eth-keyfile==0.8.1; python_version >= '3.8' and python_version < '4' eth-keys==0.6.0; python_version >= '3.8' and python_version < '4' @@ -46,6 +46,7 @@ regex==2024.11.6; python_version >= '3.8' requests==2.33.0; python_version >= '3.8' rlp==4.1.0; python_version >= '3.8' and python_version < '5' six==1.17.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' +solders>=0.27.0; python_version >= '3.7' stdiomask==0.0.6 tabulate==0.9.0; python_version >= '3.7' toolz==1.1.0; python_version >= '3.8' diff --git a/setup.cfg b/setup.cfg index 7803e7b..33351e0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -44,10 +44,11 @@ install_requires = base58 ecdsa>=0.19.2 colander - cryptnox-sdk-py>=1.0.4 + cryptnox-sdk-py>=1.0.5 lazy-import pytz requests>=2.33.0 + solders>=0.27.0 tabulate stdiomask web3>=7.15.0