From 0ec66a0d3f25b79eb630add353d3cca6d602834c Mon Sep 17 00:00:00 2001 From: YanNaingWinn Date: Wed, 1 Jul 2026 17:01:03 +0200 Subject: [PATCH 1/6] feat: add Solana (SOL) support Add native SOL send and account info for Solana, using the card's Ed25519 key. Transaction building and serialization are delegated to solders; the card signs the raw message bytes and the 64-byte signature is grafted onto the message to produce the broadcastable transaction. - wallet/solana.py: Ed25519 address derivation (base58 of the raw key), JSON-RPC client (balance/blockhash/broadcast), transfer-message building and transaction assembly, and SolanaValidator - command/solana.py: "solana send" and "solana config" commands - command/options/solana.py: argument parsing, address and network validation - card/info.py: show SOL address and balance in the info table - config/enums: default "solana" config section and SolanaNetwork enum - register the solana command in the factory Bump cryptnox-sdk-py to >=1.0.5 (Ed25519 key derivation/signing) and add solders>=0.27.0. Ignore *.egg-info/. --- .gitignore | 1 + Pipfile | 3 +- cryptnox_cli/command/card/info.py | 51 ++++++- cryptnox_cli/command/factory.py | 2 +- cryptnox_cli/command/helper/config.py | 5 +- cryptnox_cli/command/options/options.py | 2 + cryptnox_cli/command/options/solana.py | 66 +++++++++ cryptnox_cli/command/solana.py | 125 +++++++++++++++++ cryptnox_cli/config.py | 5 + cryptnox_cli/enums.py | 10 ++ cryptnox_cli/wallet/solana.py | 173 ++++++++++++++++++++++++ requirements.txt | 3 +- setup.cfg | 3 +- 13 files changed, 442 insertions(+), 7 deletions(-) create mode 100644 cryptnox_cli/command/options/solana.py create mode 100644 cryptnox_cli/command/solana.py create mode 100644 cryptnox_cli/wallet/solana.py 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..e944705 100644 --- a/cryptnox_cli/command/card/info.py +++ b/cryptnox_cli/command/card/info.py @@ -23,12 +23,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 +50,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 +116,20 @@ def _fetch_xrp_pubkey(card): except Exception: return None + @staticmethod + def _fetch_solana_pubkey(card): + # Returns None on pre-2.0 cards (the SDK raises a version-guard exception + # for Ed25519 ops) or any other communication error. + 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 Exception: + return None + @staticmethod def _get_btc_info(config, pubkey) -> dict: if pubkey is None: @@ -182,6 +202,33 @@ 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": "--"} + + # Pre-2.0 applets cannot derive an Ed25519 key, so the pubkey is None. + if pubkey is None: + tabulate_data["address"] = "Requires applet v2.0" + 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 = api.get_balance(tabulate_data["address"]) + tabulate_data["balance"] = f"{balance} 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..a866538 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: @@ -148,6 +150,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/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..739160b --- /dev/null +++ b/cryptnox_cli/command/options/solana.py @@ -0,0 +1,66 @@ +# -*- 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 + + +def _validate_decimal(value: str) -> Decimal: + try: + return Decimal(value) + except InvalidOperation: + raise argparse.ArgumentTypeError( + f"Invalid amount: '{value}'. Please provide a valid number" + ) + + +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..abd0912 --- /dev/null +++ b/cryptnox_cli/command/solana.py @@ -0,0 +1,125 @@ +# -*- 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 + + network = getattr(self.data, "network", None) or config["network"] + api = wallet.SolanaApi(network, config["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 = api.get_balance(from_address) + balance_lamports = int(balance * wallet.LAMPORTS_PER_SOL) + if balance_lamports < lamports + wallet.BASE_FEE_LAMPORTS: + print("Not enough funds for the transaction") + return -2 + + 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 + + if not Solana._confirm(from_address, self.data.address, balance, self.data.amount): + print("Canceled by the user.") + 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: float, 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/solana.py b/cryptnox_cli/wallet/solana.py new file mode 100644 index 0000000..5c509c8 --- /dev/null +++ b/cryptnox_cli/wallet/solana.py @@ -0,0 +1,173 @@ +# -*- 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 cryptnox_sdk_py import Derivation +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 . 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) -> float: + """ + Return the balance of *sol_address* in SOL (not lamports). + """ + result = self._rpc("getBalance", [sol_address]) + return result["value"] / LAMPORTS_PER_SOL + + def get_latest_blockhash(self) -> str: + """ + Return a recent blockhash as a base58 string. + """ + result = self._rpc("getLatestBlockhash", [{"commitment": "finalized"}]) + 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..b4e7452 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ 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' @@ -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 From e25fb7fac00229fa122e1e26168fa35d2e1554a7 Mon Sep 17 00:00:00 2001 From: YanNaingWinn Date: Thu, 2 Jul 2026 16:50:51 +0200 Subject: [PATCH 2/6] fix(solana): resolve Solana SOL support code-review findings (crypto verification, network/amount/balance safety, config fixes) --- .github/workflows/docs.yml | 7 +-- .github/workflows/semgrep.yml | 1 + cryptnox_cli/command/card/info.py | 28 +++++++--- cryptnox_cli/command/helper/config.py | 9 ++-- cryptnox_cli/command/options/solana.py | 12 ++++- cryptnox_cli/command/solana.py | 56 +++++++++++++++----- cryptnox_cli/wallet/solana.py | 73 +++++++++++++++++++++++--- cryptnox_cli/wallet/validators.py | 30 +++++++++++ requirements.txt | 4 +- 9 files changed, 184 insertions(+), 36 deletions(-) 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/cryptnox_cli/command/card/info.py b/cryptnox_cli/command/card/info.py index e944705..bf646f9 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 @@ -118,13 +119,21 @@ def _fetch_xrp_pubkey(card): @staticmethod def _fetch_solana_pubkey(card): - # Returns None on pre-2.0 cards (the SDK raises a version-guard exception - # for Ed25519 ops) or any other communication error. + # Returns None if the SDK lacks Ed25519, on pre-2.0 cards (the SDK raises + # a version-guard exception for Ed25519 ops), or any communication error. + if not solana_wallet.sdk_supports_ed25519(): + return None + config = get_configuration(card)["solana"] + try: + derivation = cryptnox_sdk_py.Derivation[config["derivation"]] + except KeyError: + return None + path = "" if derivation == cryptnox_sdk_py.Derivation.CURRENT_KEY else solana_wallet.PATH try: return card.get_public_key( - cryptnox_sdk_py.Derivation.DERIVE, + derivation, key_type=cryptnox_sdk_py.KeyType.ED25519, - path=solana_wallet.PATH, + path=path, compressed=False ) except Exception: @@ -207,9 +216,13 @@ def _get_solana_info(config, pubkey) -> dict: network = config.get("network", "mainnet").lower() tabulate_data = {"name": "SOL", "network": network, "balance": "--"} - # Pre-2.0 applets cannot derive an Ed25519 key, so the pubkey is None. + # pubkey is None when the SDK lacks Ed25519, or a pre-2.0 applet cannot + # derive an Ed25519 key. Distinguish the two so the user knows what to fix. if pubkey is None: - tabulate_data["address"] = "Requires applet v2.0" + if not solana_wallet.sdk_supports_ed25519(): + tabulate_data["address"] = "Requires SDK update" + else: + tabulate_data["address"] = "Requires applet v2.0" return tabulate_data try: @@ -222,7 +235,8 @@ def _get_solana_info(config, pubkey) -> dict: try: api = solana_wallet.SolanaApi(network, config.get("endpoint", "")) balance = api.get_balance(tabulate_data["address"]) - tabulate_data["balance"] = f"{balance} SOL" + # Fixed-point; avoids float exponent notation (e.g. 5e-06 SOL) for dust. + tabulate_data["balance"] = f"{Decimal(str(balance)):f} SOL" except Exception as error: print(f"There's an issue in retrieving Solana balance: {error}") tabulate_data["balance"] = "Network issue" diff --git a/cryptnox_cli/command/helper/config.py b/cryptnox_cli/command/helper/config.py index a866538..5422998 100644 --- a/cryptnox_cli/command/helper/config.py +++ b/cryptnox_cli/command/helper/config.py @@ -124,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 + # Only btc derives its endpoint from the network (it is read-only); for + # every other section (eth, solana, eosio) the endpoint is a real, + # user-editable key and must be printed as-is, not remapped to network. + remap = key == "endpoint" and section == "btc" + key = "network" if remap 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 remap: print(endpoint) else: print(f"{key}: {value}") diff --git a/cryptnox_cli/command/options/solana.py b/cryptnox_cli/command/options/solana.py index 739160b..5c311c5 100644 --- a/cryptnox_cli/command/options/solana.py +++ b/cryptnox_cli/command/options/solana.py @@ -22,12 +22,22 @@ def _validate_decimal(value: str) -> Decimal: try: - return Decimal(value) + amount = Decimal(value) except InvalidOperation: raise argparse.ArgumentTypeError( f"Invalid amount: '{value}'. Please provide a valid number" ) + # Decimal accepts NaN/Infinity and negatives; none are valid transfer amounts. + if not amount.is_finite(): + raise argparse.ArgumentTypeError( + f"Invalid amount: '{value}'. Please provide a finite number" + ) + if amount <= 0: + raise argparse.ArgumentTypeError("Amount must be greater than 0") + + return amount + def _network_choices(): return [e.name.lower() for e in enums.SolanaNetwork] diff --git a/cryptnox_cli/command/solana.py b/cryptnox_cli/command/solana.py index abd0912..b34ffc9 100644 --- a/cryptnox_cli/command/solana.py +++ b/cryptnox_cli/command/solana.py @@ -41,10 +41,8 @@ def _execute(self, card) -> int: 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: + # requests.HTTPError is a subclass, so this covers both. print(f"There was an issue in communication: {error}") return -1 except ValueError as error: @@ -54,6 +52,11 @@ def _execute(self, card) -> int: return 0 def _send(self, card) -> int: + if not wallet.sdk_supports_ed25519(): + print("This version of cryptnox-sdk-py does not support Solana (Ed25519).\n" + "Update it with: pip install --upgrade cryptnox-sdk-py") + return -1 + config = get_configuration(card)["solana"] try: @@ -62,8 +65,12 @@ def _send(self, card) -> int: print("Derivation is invalid") return 1 - network = getattr(self.data, "network", None) or config["network"] - api = wallet.SolanaApi(network, config["endpoint"]) + # An explicit --network must never be silently overridden by a configured + # endpoint (which may point at a different network); ignore the override then. + 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, @@ -71,26 +78,45 @@ def _send(self, card) -> int: from_address = wallet.address(public_key) lamports = int(self.data.amount * wallet.LAMPORTS_PER_SOL) + if lamports <= 0: + print("Amount is too small: it rounds to 0 lamports") + return 1 - balance = api.get_balance(from_address) - balance_lamports = int(balance * wallet.LAMPORTS_PER_SOL) + balance_lamports = api.get_balance_lamports(from_address) if balance_lamports < lamports + wallet.BASE_FEE_LAMPORTS: print("Not enough funds for the transaction") return -2 + if lamports < wallet.RENT_EXEMPT_LAMPORTS: + print(f"\nWarning: {self.data.amount} SOL is below the rent-exempt minimum " + f"(~{wallet.RENT_EXEMPT_LAMPORTS / wallet.LAMPORTS_PER_SOL} SOL). " + "A transfer to a new account may be rejected by the network.") + + balance = balance_lamports / wallet.LAMPORTS_PER_SOL + if not Solana._confirm(from_address, self.data.address, balance, self.data.amount, + api.url): + print("Canceled by the user.") + return -1 + + # Confirm first, then fetch the blockhash and sign: the blockhash is only + # valid for ~60-90 s, so fetching it before a blocking prompt risks expiry, + # and signing before confirmation would consume the card counter on decline. 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") + try: + signature = sign(card, message_bytes, derivation, + key_type=cryptnox_sdk_py.KeyType.ED25519, path=path) + except ValueError as error: + print(f"Error signing with the card: {error}") return -1 - if not Solana._confirm(from_address, self.data.address, balance, self.data.amount): - print("Canceled by the user.") + # Belt-and-braces: reject a signature the card cannot verify against its + # own derived key before we broadcast anything. + if not wallet.verify_signature(public_key, message_bytes, signature): + print("Card signature failed verification; aborting before broadcast.") return -1 transaction = wallet.assemble_transaction(message, signature) @@ -102,7 +128,8 @@ def _send(self, card) -> int: return 0 @staticmethod - def _confirm(from_address: str, to_address: str, balance: float, amount: Decimal) -> bool: + def _confirm(from_address: str, to_address: str, balance: float, amount: Decimal, + rpc_url: str = "") -> bool: fee = Decimal(wallet.BASE_FEE_LAMPORTS) / wallet.LAMPORTS_PER_SOL def sol(value) -> str: @@ -114,6 +141,7 @@ def sol(value) -> str: ["TRANSACTION:", sol(amount), "SOL", "TO", "ACCOUNT:", f"{to_address}"], ["MAX FEE:", sol(fee)], ["MAX TOTAL:", sol(amount + fee)], + ["NETWORK:", rpc_url], ] print("\n\n--- Transaction Ready --- \n") diff --git a/cryptnox_cli/wallet/solana.py b/cryptnox_cli/wallet/solana.py index 5c509c8..3a10918 100644 --- a/cryptnox_cli/wallet/solana.py +++ b/cryptnox_cli/wallet/solana.py @@ -15,6 +15,7 @@ import base64 import base58 +import cryptnox_sdk_py import requests from cryptnox_sdk_py import Derivation from solders.hash import Hash @@ -41,6 +42,11 @@ # this is the worst-case network fee for the balance check. BASE_FEE_LAMPORTS = 5_000 +# Minimum balance the runtime requires an account to hold to stay rent-exempt +# (~0.00089 SOL for a 0-data system account). Transfers that would create an +# account below this, or drain the sender below it, are rejected by preflight. +RENT_EXEMPT_LAMPORTS = 890_880 + MAINNET = "https://api.mainnet-beta.solana.com" DEVNET = "https://api.devnet.solana.com" TESTNET = "https://api.testnet.solana.com" @@ -52,21 +58,40 @@ } +def sdk_supports_ed25519() -> bool: + """ + Whether the installed cryptnox-sdk-py exposes Ed25519 signing. + + Ed25519 (Solana) support landed in cryptnox-sdk-py 1.0.5; older releases + expose only K1/R1 and lack ``KeyType.ED25519``. This lets the CLI tell + "SDK too old" apart from "applet too old". + + :rtype: bool + """ + return hasattr(cryptnox_sdk_py.KeyType, "ED25519") + + 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. + from the network name. For a money-moving tool an unrecognised network name + is an error rather than a silent fall-back to mainnet. :param str network: Network name (mainnet/devnet/testnet) :param str endpoint: Optional explicit RPC URL override :return: RPC URL to use :rtype: str + :raises ValueError: If ``network`` is unknown and no endpoint override is given """ if endpoint: return endpoint - return _NETWORK_RPC.get((network or "mainnet").lower(), MAINNET) + key = (network or "").lower() + try: + return _NETWORK_RPC[key] + except KeyError: + raise ValueError(f"Unknown Solana network: {network!r}") def address(public_key_hex: str) -> str: @@ -115,6 +140,28 @@ def assemble_transaction(message: Message, signature: bytes) -> Transaction: return Transaction.populate(message, [Signature.from_bytes(signature)]) +def verify_signature(public_key_hex: str, message_bytes: bytes, signature: bytes) -> bool: + """ + Verify a card-produced Ed25519 signature against the signed message and the + derived public key. + + Belt-and-braces check before broadcasting: it catches a card/applet that + derives or signs incorrectly (e.g. a SLIP-0010 deviation) before any + funds-moving RPC call is made. + + :param str public_key_hex: Signer's raw 32-byte Ed25519 public key as hex + :param bytes message_bytes: The exact bytes that were signed + :param bytes signature: Raw 64-byte Ed25519 signature from the card + :return: True if the signature is valid for the key and message + :rtype: bool + """ + try: + pubkey = Pubkey.from_bytes(bytes.fromhex(public_key_hex)) + return Signature.from_bytes(signature).verify(pubkey, message_bytes) + except Exception: + return False + + class SolanaApi: """ Thin JSON-RPC client for the Solana network (balance, blockhash, broadcast). @@ -132,12 +179,24 @@ def _rpc(self, method: str, params: list): raise ValueError(data["error"].get("message", "Solana RPC error")) return data["result"] + def get_balance_lamports(self, sol_address: str) -> int: + """ + Return the exact balance of *sol_address* in lamports (integer). + + Use this for all arithmetic and funds checks; ``getBalance`` returns an + exact integer that must not be round-tripped through a float. + """ + result = self._rpc("getBalance", [sol_address]) + return int(result["value"]) + def get_balance(self, sol_address: str) -> float: """ Return the balance of *sol_address* in SOL (not lamports). + + This is a lossy float intended for display only; use + :meth:`get_balance_lamports` for any comparison or arithmetic. """ - result = self._rpc("getBalance", [sol_address]) - return result["value"] / LAMPORTS_PER_SOL + return self.get_balance_lamports(sol_address) / LAMPORTS_PER_SOL def get_latest_blockhash(self) -> str: """ @@ -158,9 +217,11 @@ class SolanaValidator: """ Class defining Solana configuration validators """ - derivation = validators.EnumValidator(Derivation) + # Ed25519 get_public_key/sign reject PINLESS_PATH and DERIVE_AND_MAKE_CURRENT, + # so only these two derivations are usable for Solana. + derivation = validators.ChoiceValidator(["CURRENT_KEY", "DERIVE"]) network = validators.EnumValidator(enums.SolanaNetwork) - endpoint = validators.AnyValidator() + endpoint = validators.OptionalUrlValidator() def __init__(self, derivation: str = "DERIVE", network: str = "mainnet", endpoint: str = ""): diff --git a/cryptnox_cli/wallet/validators.py b/cryptnox_cli/wallet/validators.py index f860039..7bc90b3 100644 --- a/cryptnox_cli/wallet/validators.py +++ b/cryptnox_cli/wallet/validators.py @@ -86,6 +86,24 @@ def validate(self, value) -> None: return value +class ChoiceValidator(Validator): + """ + Class for validating that a value is one of an explicit allow-list of names + (case-insensitive). Used where an enum has more members than are valid for a + specific operation. + """ + + def __init__(self, allowed): + self.allowed = [value.upper() for value in allowed] + super().__init__("\n".join(self.allowed)) + + def validate(self, value) -> str: + value = value.upper() + if value not in self.allowed: + raise ValidationError("Invalid value") + return value + + class UrlValidator(Validator): """ Class for validating URLs @@ -105,6 +123,18 @@ def validate(self, value: str) -> str: return value +class OptionalUrlValidator(UrlValidator): + """ + URL validator that also accepts the empty string, used for optional RPC + endpoint overrides where "" means "no override, derive from the network". + """ + + def validate(self, value: str) -> str: + if value == "": + return value + return super().validate(value) + + def is_int(value: Any) -> bool: value = str(value) if value[0] in ('-', '+'): diff --git a/requirements.txt b/requirements.txt index b4e7452..6470ad7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ 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.5; 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' @@ -46,7 +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' +solders==0.27.1; python_version >= '3.7' stdiomask==0.0.6 tabulate==0.9.0; python_version >= '3.7' toolz==1.1.0; python_version >= '3.8' From f0fcb92392aaca5a86d908931650f963d0bad398 Mon Sep 17 00:00:00 2001 From: YanNaingWinn Date: Tue, 14 Jul 2026 21:14:31 +0700 Subject: [PATCH 3/6] feat: add Solana (SOL) support Add the Solana command and wallet backend (send/transaction flow, address validation), enum/config/factory wiring, and CLI options; repoint BTC mainnet to blockstream.info and add base58/solders deps. Align signing with SDK v2.0 SIGN, with amount bounds, confirm-before-sign, and network/endpoint handling. Drop the dead old_key local in config.py so flake8 passes without a workflow ignore. --- cryptnox_cli/command/card/info.py | 39 ++++----- cryptnox_cli/command/helper/config.py | 13 +-- cryptnox_cli/command/helper/helper_methods.py | 7 +- cryptnox_cli/command/options/solana.py | 14 ++-- cryptnox_cli/command/solana.py | 52 ++++-------- cryptnox_cli/wallet/btc.py | 4 +- cryptnox_cli/wallet/solana.py | 82 ++++--------------- cryptnox_cli/wallet/validators.py | 30 ------- requirements.txt | 6 +- 9 files changed, 71 insertions(+), 176 deletions(-) diff --git a/cryptnox_cli/command/card/info.py b/cryptnox_cli/command/card/info.py index bf646f9..877a8f0 100644 --- a/cryptnox_cli/command/card/info.py +++ b/cryptnox_cli/command/card/info.py @@ -119,25 +119,21 @@ def _fetch_xrp_pubkey(card): @staticmethod def _fetch_solana_pubkey(card): - # Returns None if the SDK lacks Ed25519, on pre-2.0 cards (the SDK raises - # a version-guard exception for Ed25519 ops), or any communication error. - if not solana_wallet.sdk_supports_ed25519(): - return None - config = get_configuration(card)["solana"] - try: - derivation = cryptnox_sdk_py.Derivation[config["derivation"]] - except KeyError: - return None - path = "" if derivation == cryptnox_sdk_py.Derivation.CURRENT_KEY else solana_wallet.PATH + # 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( - derivation, + cryptnox_sdk_py.Derivation.DERIVE, key_type=cryptnox_sdk_py.KeyType.ED25519, - path=path, + path=solana_wallet.PATH, compressed=False ) - except Exception: + except cryptnox_sdk_py.exceptions.AppletVersionException: return None + except Exception: + return "" @staticmethod def _get_btc_info(config, pubkey) -> dict: @@ -216,13 +212,12 @@ def _get_solana_info(config, pubkey) -> dict: network = config.get("network", "mainnet").lower() tabulate_data = {"name": "SOL", "network": network, "balance": "--"} - # pubkey is None when the SDK lacks Ed25519, or a pre-2.0 applet cannot - # derive an Ed25519 key. Distinguish the two so the user knows what to fix. + # None -> pre-2.0 applet (no Ed25519); "" -> derivation/communication error. if pubkey is None: - if not solana_wallet.sdk_supports_ed25519(): - tabulate_data["address"] = "Requires SDK update" - else: - tabulate_data["address"] = "Requires applet v2.0" + tabulate_data["address"] = "Requires applet v2.0" + return tabulate_data + if not pubkey: + tabulate_data["address"] = "Error" return tabulate_data try: @@ -234,9 +229,9 @@ def _get_solana_info(config, pubkey) -> dict: try: api = solana_wallet.SolanaApi(network, config.get("endpoint", "")) - balance = api.get_balance(tabulate_data["address"]) - # Fixed-point; avoids float exponent notation (e.g. 5e-06 SOL) for dust. - tabulate_data["balance"] = f"{Decimal(str(balance)):f} SOL" + 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" diff --git a/cryptnox_cli/command/helper/config.py b/cryptnox_cli/command/helper/config.py index 5422998..7eb2371 100644 --- a/cryptnox_cli/command/helper/config.py +++ b/cryptnox_cli/command/helper/config.py @@ -124,14 +124,15 @@ def print_key_config(card: cryptnox_sdk_py.Card, section: str, key: str) -> int: """ config = get_configuration(card) try: - # Only btc derives its endpoint from the network (it is read-only); for - # every other section (eth, solana, eosio) the endpoint is a real, - # user-editable key and must be printed as-is, not remapped to network. - remap = key == "endpoint" and section == "btc" - key = "network" if remap 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") + old_key = key + key = "network" if _derived_endpoint else key value = config[section][key] endpoint = find_endpoint(section, key, value, " - YOU CAN'T EDIT THIS") - if remap: + if _derived_endpoint: print(endpoint) else: print(f"{key}: {value}") 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/solana.py b/cryptnox_cli/command/options/solana.py index 5c311c5..dff8c99 100644 --- a/cryptnox_cli/command/options/solana.py +++ b/cryptnox_cli/command/options/solana.py @@ -20,6 +20,11 @@ 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) @@ -27,15 +32,12 @@ def _validate_decimal(value: str) -> Decimal: raise argparse.ArgumentTypeError( f"Invalid amount: '{value}'. Please provide a valid number" ) - - # Decimal accepts NaN/Infinity and negatives; none are valid transfer amounts. if not amount.is_finite(): - raise argparse.ArgumentTypeError( - f"Invalid amount: '{value}'. Please provide a finite number" - ) + 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 diff --git a/cryptnox_cli/command/solana.py b/cryptnox_cli/command/solana.py index b34ffc9..7d69d70 100644 --- a/cryptnox_cli/command/solana.py +++ b/cryptnox_cli/command/solana.py @@ -41,8 +41,10 @@ def _execute(self, card) -> int: 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: - # requests.HTTPError is a subclass, so this covers both. print(f"There was an issue in communication: {error}") return -1 except ValueError as error: @@ -52,11 +54,6 @@ def _execute(self, card) -> int: return 0 def _send(self, card) -> int: - if not wallet.sdk_supports_ed25519(): - print("This version of cryptnox-sdk-py does not support Solana (Ed25519).\n" - "Update it with: pip install --upgrade cryptnox-sdk-py") - return -1 - config = get_configuration(card)["solana"] try: @@ -65,8 +62,9 @@ def _send(self, card) -> int: print("Derivation is invalid") return 1 - # An explicit --network must never be silently overridden by a configured - # endpoint (which may point at a different network); ignore the override then. + # 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"] @@ -78,45 +76,29 @@ def _send(self, card) -> int: from_address = wallet.address(public_key) lamports = int(self.data.amount * wallet.LAMPORTS_PER_SOL) - if lamports <= 0: - print("Amount is too small: it rounds to 0 lamports") - return 1 - balance_lamports = api.get_balance_lamports(from_address) + balance_lamports = api.get_balance(from_address) if balance_lamports < lamports + wallet.BASE_FEE_LAMPORTS: print("Not enough funds for the transaction") return -2 - if lamports < wallet.RENT_EXEMPT_LAMPORTS: - print(f"\nWarning: {self.data.amount} SOL is below the rent-exempt minimum " - f"(~{wallet.RENT_EXEMPT_LAMPORTS / wallet.LAMPORTS_PER_SOL} SOL). " - "A transfer to a new account may be rejected by the network.") + balance_sol = Decimal(balance_lamports) / wallet.LAMPORTS_PER_SOL - balance = balance_lamports / wallet.LAMPORTS_PER_SOL - if not Solana._confirm(from_address, self.data.address, balance, self.data.amount, - api.url): + # 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 - # Confirm first, then fetch the blockhash and sign: the blockhash is only - # valid for ~60-90 s, so fetching it before a blocking prompt risks expiry, - # and signing before confirmation would consume the card counter on decline. 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") - try: - signature = sign(card, message_bytes, derivation, - key_type=cryptnox_sdk_py.KeyType.ED25519, path=path) - except ValueError as error: - print(f"Error signing with the card: {error}") - return -1 - - # Belt-and-braces: reject a signature the card cannot verify against its - # own derived key before we broadcast anything. - if not wallet.verify_signature(public_key, message_bytes, signature): - print("Card signature failed verification; aborting before broadcast.") + 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) @@ -128,8 +110,7 @@ def _send(self, card) -> int: return 0 @staticmethod - def _confirm(from_address: str, to_address: str, balance: float, amount: Decimal, - rpc_url: str = "") -> bool: + 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: @@ -141,7 +122,6 @@ def sol(value) -> str: ["TRANSACTION:", sol(amount), "SOL", "TO", "ACCOUNT:", f"{to_address}"], ["MAX FEE:", sol(fee)], ["MAX TOTAL:", sol(amount + fee)], - ["NETWORK:", rpc_url], ] print("\n\n--- Transaction Ready --- \n") 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 index 3a10918..58a2791 100644 --- a/cryptnox_cli/wallet/solana.py +++ b/cryptnox_cli/wallet/solana.py @@ -15,7 +15,6 @@ import base64 import base58 -import cryptnox_sdk_py import requests from cryptnox_sdk_py import Derivation from solders.hash import Hash @@ -42,11 +41,6 @@ # this is the worst-case network fee for the balance check. BASE_FEE_LAMPORTS = 5_000 -# Minimum balance the runtime requires an account to hold to stay rent-exempt -# (~0.00089 SOL for a 0-data system account). Transfers that would create an -# account below this, or drain the sender below it, are rejected by preflight. -RENT_EXEMPT_LAMPORTS = 890_880 - MAINNET = "https://api.mainnet-beta.solana.com" DEVNET = "https://api.devnet.solana.com" TESTNET = "https://api.testnet.solana.com" @@ -58,40 +52,21 @@ } -def sdk_supports_ed25519() -> bool: - """ - Whether the installed cryptnox-sdk-py exposes Ed25519 signing. - - Ed25519 (Solana) support landed in cryptnox-sdk-py 1.0.5; older releases - expose only K1/R1 and lack ``KeyType.ED25519``. This lets the CLI tell - "SDK too old" apart from "applet too old". - - :rtype: bool - """ - return hasattr(cryptnox_sdk_py.KeyType, "ED25519") - - 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. For a money-moving tool an unrecognised network name - is an error rather than a silent fall-back to mainnet. + 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 - :raises ValueError: If ``network`` is unknown and no endpoint override is given """ if endpoint: return endpoint - key = (network or "").lower() - try: - return _NETWORK_RPC[key] - except KeyError: - raise ValueError(f"Unknown Solana network: {network!r}") + return _NETWORK_RPC.get((network or "mainnet").lower(), MAINNET) def address(public_key_hex: str) -> str: @@ -140,28 +115,6 @@ def assemble_transaction(message: Message, signature: bytes) -> Transaction: return Transaction.populate(message, [Signature.from_bytes(signature)]) -def verify_signature(public_key_hex: str, message_bytes: bytes, signature: bytes) -> bool: - """ - Verify a card-produced Ed25519 signature against the signed message and the - derived public key. - - Belt-and-braces check before broadcasting: it catches a card/applet that - derives or signs incorrectly (e.g. a SLIP-0010 deviation) before any - funds-moving RPC call is made. - - :param str public_key_hex: Signer's raw 32-byte Ed25519 public key as hex - :param bytes message_bytes: The exact bytes that were signed - :param bytes signature: Raw 64-byte Ed25519 signature from the card - :return: True if the signature is valid for the key and message - :rtype: bool - """ - try: - pubkey = Pubkey.from_bytes(bytes.fromhex(public_key_hex)) - return Signature.from_bytes(signature).verify(pubkey, message_bytes) - except Exception: - return False - - class SolanaApi: """ Thin JSON-RPC client for the Solana network (balance, blockhash, broadcast). @@ -179,30 +132,25 @@ def _rpc(self, method: str, params: list): raise ValueError(data["error"].get("message", "Solana RPC error")) return data["result"] - def get_balance_lamports(self, sol_address: str) -> int: + def get_balance(self, sol_address: str) -> int: """ - Return the exact balance of *sol_address* in lamports (integer). + Return the balance of *sol_address* in lamports. - Use this for all arithmetic and funds checks; ``getBalance`` returns an - exact integer that must not be round-tripped through a float. + 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 int(result["value"]) - - def get_balance(self, sol_address: str) -> float: - """ - Return the balance of *sol_address* in SOL (not lamports). - - This is a lossy float intended for display only; use - :meth:`get_balance_lamports` for any comparison or arithmetic. - """ - return self.get_balance_lamports(sol_address) / LAMPORTS_PER_SOL + 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": "finalized"}]) + result = self._rpc("getLatestBlockhash", [{"commitment": "confirmed"}]) return result["value"]["blockhash"] def send_transaction(self, transaction: Transaction) -> str: @@ -217,11 +165,9 @@ class SolanaValidator: """ Class defining Solana configuration validators """ - # Ed25519 get_public_key/sign reject PINLESS_PATH and DERIVE_AND_MAKE_CURRENT, - # so only these two derivations are usable for Solana. - derivation = validators.ChoiceValidator(["CURRENT_KEY", "DERIVE"]) + derivation = validators.EnumValidator(Derivation) network = validators.EnumValidator(enums.SolanaNetwork) - endpoint = validators.OptionalUrlValidator() + endpoint = validators.AnyValidator() def __init__(self, derivation: str = "DERIVE", network: str = "mainnet", endpoint: str = ""): diff --git a/cryptnox_cli/wallet/validators.py b/cryptnox_cli/wallet/validators.py index 7bc90b3..f860039 100644 --- a/cryptnox_cli/wallet/validators.py +++ b/cryptnox_cli/wallet/validators.py @@ -86,24 +86,6 @@ def validate(self, value) -> None: return value -class ChoiceValidator(Validator): - """ - Class for validating that a value is one of an explicit allow-list of names - (case-insensitive). Used where an enum has more members than are valid for a - specific operation. - """ - - def __init__(self, allowed): - self.allowed = [value.upper() for value in allowed] - super().__init__("\n".join(self.allowed)) - - def validate(self, value) -> str: - value = value.upper() - if value not in self.allowed: - raise ValidationError("Invalid value") - return value - - class UrlValidator(Validator): """ Class for validating URLs @@ -123,18 +105,6 @@ def validate(self, value: str) -> str: return value -class OptionalUrlValidator(UrlValidator): - """ - URL validator that also accepts the empty string, used for optional RPC - endpoint overrides where "" means "no override, derive from the network". - """ - - def validate(self, value: str) -> str: - if value == "": - return value - return super().validate(value) - - def is_int(value: Any) -> bool: value = str(value) if value[0] in ('-', '+'): diff --git a/requirements.txt b/requirements.txt index 6470ad7..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.5; 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,7 +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.1; python_version >= '3.7' +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' From 39d36a0afce3572fd1288bd5dd3d3bdfebbf3559 Mon Sep 17 00:00:00 2001 From: YanNaingWinn Date: Tue, 14 Jul 2026 21:17:10 +0700 Subject: [PATCH 4/6] fix: drop unused old_key local in config.py to clear flake8 F841 --- cryptnox_cli/command/helper/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cryptnox_cli/command/helper/config.py b/cryptnox_cli/command/helper/config.py index 7eb2371..d33de22 100644 --- a/cryptnox_cli/command/helper/config.py +++ b/cryptnox_cli/command/helper/config.py @@ -128,7 +128,6 @@ def print_key_config(card: cryptnox_sdk_py.Card, section: str, key: str) -> int: # 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") - old_key = key key = "network" if _derived_endpoint else key value = config[section][key] endpoint = find_endpoint(section, key, value, " - YOU CAN'T EDIT THIS") From 485743cc14d3fd67a37b7469aaba287f6411d3a0 Mon Sep 17 00:00:00 2001 From: YanNaingWinn Date: Tue, 14 Jul 2026 21:21:10 +0700 Subject: [PATCH 5/6] Potential fix for pull request finding 'CodeQL / Unused import' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- cryptnox_cli/wallet/solana.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cryptnox_cli/wallet/solana.py b/cryptnox_cli/wallet/solana.py index 58a2791..79b775a 100644 --- a/cryptnox_cli/wallet/solana.py +++ b/cryptnox_cli/wallet/solana.py @@ -16,7 +16,6 @@ import base58 import requests -from cryptnox_sdk_py import Derivation from solders.hash import Hash from solders.message import Message from solders.pubkey import Pubkey From 4ad6575ea4d5af211a4c2b4ff1f09c2e71ae9f60 Mon Sep 17 00:00:00 2001 From: YanNaingWinn Date: Tue, 14 Jul 2026 21:24:04 +0700 Subject: [PATCH 6/6] fix: restore Derivation import in solana.py to clear flake8 F821 --- cryptnox_cli/wallet/solana.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cryptnox_cli/wallet/solana.py b/cryptnox_cli/wallet/solana.py index 79b775a..adda53f 100644 --- a/cryptnox_cli/wallet/solana.py +++ b/cryptnox_cli/wallet/solana.py @@ -23,6 +23,8 @@ from solders.system_program import TransferParams, transfer from solders.transaction import Transaction +from cryptnox_sdk_py import Derivation + from . import validators try: