diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c87d32..04fe388 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # turbo-python-sdk +## 0.1.0 + +### Added + +- `SolanaSigner` for signing data items with a Solana (ed25519) wallet. Accepts a Solana CLI `id.json` (via `SolanaSigner.from_file()` or a 64-int list), a base58-encoded secret key, a raw 64-byte secret key, or a 32-byte seed; the wallet address is the base58 of the ed25519 public key. Uses ANS-104 signature type 2 (raw ED25519), which Turbo bills as the `solana` token. Verified end-to-end against the upload backend and cross-checked byte-for-byte with `@dha-team/arbundles`. +- `TOKEN_MAP` entry mapping signature type 2 to the `solana` token so `Turbo(SolanaSigner(...))` initializes correctly. + +### Fixed + +- Aligned `pyproject.toml` version (was `0.0.6`) with `turbo_sdk.__version__` (`0.1.0`), which had drifted apart. + ## 0.0.6 ### Added diff --git a/README.md b/README.md index 8616a79..63686e1 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,30 @@ result = turbo.upload(b"Hello from Arweave!", tags=[ print(f"โœ… Uploaded! URI: ar://{result.id}") ``` +#### Solana Usage + +```python +from turbo_sdk import Turbo, SolanaSigner + +# Load a Solana CLI keypair (id.json: JSON array of 64 ints) +signer = SolanaSigner.from_file("~/.config/solana/id.json") + +# Or construct directly from a raw 64-byte secret key, a 32-byte seed, +# a CLI keypair list/JSON string, or a base58-encoded secret key: +# signer = SolanaSigner(secret_bytes) +# signer = SolanaSigner("4Nd1m...") # base58 secret key + +# Create Turbo client (billed as the "solana" token) +turbo = Turbo(signer, network="mainnet") + +# Upload data +result = turbo.upload(b"Hello from Solana!", tags=[ + {"name": "Content-Type", "value": "text/plain"} +]) + +print(f"โœ… Uploaded! URI: ar://{result.id}") +``` + ## APIs ### Core Classes @@ -67,7 +91,7 @@ Main client for interacting with Turbo services. **Parameters:** -- `signer`: Either `EthereumSigner` or `ArweaveSigner` instance +- `signer`: An `EthereumSigner`, `ArweaveSigner`, or `SolanaSigner` instance - `network`: `"mainnet"` or `"testnet"` (default: `"mainnet"`) - `upload_url`: Optional custom upload service URL (overrides network default) - `payment_url`: Optional custom payment service URL (overrides network default) @@ -234,9 +258,31 @@ signer = ArweaveSigner({ }) ``` +#### `SolanaSigner(secret_key)` + +Solana signer using ed25519 signatures (ANS-104 signature type 2). The wallet +address is the base58 encoding of the 32-byte ed25519 public key. Turbo bills +uploads from this signer as the `solana` token. + +**Parameters:** + +- `secret_key`: One of: + - a Solana CLI keypair (`id.json` contents) as a `list` of 64 ints or its JSON string + - a raw 64-byte secret key (`bytes`/`bytearray`, seed โ€– public key) + - a raw 32-byte seed (`bytes`/`bytearray`) + - a base58-encoded secret key (`str`) + +```python +# From a CLI keypair file: +signer = SolanaSigner.from_file("~/.config/solana/id.json") + +# Or directly: +signer = SolanaSigner(secret_key_bytes) +``` + #### Signer Methods -Both signers provide: +All signers provide: ##### `get_wallet_address() -> str` diff --git a/examples/arweave_upload.py b/examples/arweave_upload.py index 3866d8e..248928c 100644 --- a/examples/arweave_upload.py +++ b/examples/arweave_upload.py @@ -63,7 +63,7 @@ def main(): print(f"๐Ÿ”— URI: ar://{result.id}") print(f"๐Ÿ’ธ Cost: {result.winc} winc") print(f"๐ŸŒ Gateway URL: https://arweave.net/{result.id}") - + # v0.0.6 fields if result.timestamp: print(f"โฐ Timestamp: {result.timestamp}") diff --git a/examples/solana_upload.py b/examples/solana_upload.py new file mode 100644 index 0000000..a0f2d59 --- /dev/null +++ b/examples/solana_upload.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" +Example: Upload data using a Solana wallet +""" + +from turbo_sdk import Turbo, SolanaSigner + + +def main(): + # Solana secret key. Any of these forms work: + # - a Solana CLI ``id.json`` (use ``SolanaSigner.from_file("id.json")``) + # - a base58-encoded secret key (Phantom "export private key") + # - raw 64-byte secret key (seed||pubkey) or a 32-byte seed + # Replace with your actual key (base58 shown here). + secret_key = "your-base58-encoded-solana-secret-key" + + # Create signer and Turbo client + signer = SolanaSigner(secret_key) + turbo = Turbo(signer, network="mainnet") # or "testnet" + + print(f"๐Ÿ”‘ Connected with Solana signer ({signer.get_wallet_address()})") + + # Check balance + try: + balance = turbo.get_balance() + print(f"๐Ÿ’ฐ Balance: {balance.winc} winc") + except Exception as e: + print(f"โš ๏ธ Could not fetch balance: {e}") + + # Prepare data to upload + data = b"Hello, Turbo from Solana!" + + # Get upload cost + try: + cost = turbo.get_upload_price(len(data)) + print(f"๐Ÿ’ธ Upload cost: {cost} winc") + except Exception as e: + print(f"โš ๏ธ Could not fetch price: {e}") + + # Upload data (files under 100 KiB are free-tier) + try: + result = turbo.upload( + data, + tags=[ + {"name": "Content-Type", "value": "text/plain"}, + {"name": "App-Name", "value": "Turbo-SDK-Python"}, + {"name": "Source", "value": "Solana"}, + ], + ) + + print("โœ… Upload successful!") + print(f"๐Ÿ“„ Transaction ID: {result.id}") + print(f"๐Ÿ’ธ Cost: {result.winc} winc") + print(f"๐Ÿš€ Data caches: {result.data_caches}") + print(f"๐ŸŒ Gateway URL: https://arweave.net/{result.id}") + + except Exception as e: + print(f"โŒ Upload failed: {e}") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 380544e..14426b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "turbo-sdk" -version = "0.0.6" +version = "0.1.0" description = "Python SDK for interacting with the Ardrive Turbo Upload and Payment Service" readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_solana_signer.py b/tests/test_solana_signer.py new file mode 100644 index 0000000..fdc0f35 --- /dev/null +++ b/tests/test_solana_signer.py @@ -0,0 +1,307 @@ +import json + +import pytest +from base58 import b58encode, b58decode +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives import serialization + +from turbo_sdk.signers.solana import SolanaSigner + + +# --------------------------------------------------------------------------- +# Fixed known-answer key material. +# +# RFC 8032 Ed25519 Test Vector 1 (https://www.rfc-editor.org/rfc/rfc8032). +# Solana's ed25519 is the standard RFC 8032 scheme, so these pin the +# derivation exactly. SECRET (seed) and the corresponding PUBLIC key are both +# published in the RFC; we assert the SDK derives PUBLIC from SECRET so a typo +# in either constant fails loudly rather than passing silently. +# --------------------------------------------------------------------------- +RFC8032_TEST1_SEED_HEX = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60" +RFC8032_TEST1_PUBLIC_HEX = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" +# Signature over the empty message under the TEST 1 key (RFC 8032 ยง7.1). +RFC8032_TEST1_SIG_HEX = ( + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155" + "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b" +) + + +def _ref_b58encode(b: bytes) -> str: + """Independent reference base58 (Bitcoin alphabet) encoder for cross-check.""" + alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + n = int.from_bytes(b, "big") + out = "" + while n > 0: + n, rem = divmod(n, 58) + out = alphabet[rem] + out + # Preserve leading zero bytes as leading '1's. + pad = 0 + for byte in b: + if byte == 0: + pad += 1 + else: + break + return "1" * pad + out + + +def _derive_pubkey(seed: bytes) -> bytes: + priv = Ed25519PrivateKey.from_private_bytes(seed) + return priv.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + + +@pytest.fixture +def seed() -> bytes: + return bytes.fromhex(RFC8032_TEST1_SEED_HEX) + + +@pytest.fixture +def cli_keypair(seed) -> list: + """Solana CLI id.json layout: 64 ints = 32-byte seed || 32-byte pubkey.""" + pub = _derive_pubkey(seed) + return list(seed + pub) + + +@pytest.fixture +def signer(seed) -> SolanaSigner: + return SolanaSigner(seed) + + +class TestSolanaSigner: + """Solana signer functionality, mirroring the arweave/ethereum signer tests.""" + + def test_class_attributes(self): + # Solana uses signature type 2 (raw ED25519), not 4 (HexInjectedSolana). + assert SolanaSigner.signature_type == 2 + assert SolanaSigner.signature_length == 64 + assert SolanaSigner.owner_length == 32 + + def test_instance_attributes(self, signer): + assert signer.signature_type == 2 + assert signer.signature_length == 64 + assert signer.owner_length == 32 + + def test_public_key_is_32_bytes(self, signer): + assert len(signer.public_key) == 32 + assert isinstance(signer.public_key, bytearray) + + def test_public_key_consistency(self, seed): + assert SolanaSigner(seed).public_key == SolanaSigner(seed).public_key + + def test_different_seeds_different_public_keys(self): + s1 = SolanaSigner(bytes([1] * 32)) + s2 = SolanaSigner(bytes([2] * 32)) + assert s1.public_key != s2.public_key + + # --- constructor format support --------------------------------------- + + def test_init_from_32_byte_seed(self, seed): + s = SolanaSigner(seed) + assert len(s.public_key) == 32 + + def test_init_from_64_byte_secret_key(self, seed): + pub = _derive_pubkey(seed) + s = SolanaSigner(seed + pub) + assert bytes(s.public_key) == pub + + def test_init_from_cli_keypair_list(self, cli_keypair, seed): + s = SolanaSigner(cli_keypair) + assert bytes(s.public_key) == _derive_pubkey(seed) + + def test_init_from_cli_keypair_json_string(self, cli_keypair, seed): + s = SolanaSigner(json.dumps(cli_keypair)) + assert bytes(s.public_key) == _derive_pubkey(seed) + + def test_init_from_file(self, tmp_path, cli_keypair, seed): + p = tmp_path / "id.json" + p.write_text(json.dumps(cli_keypair)) + s = SolanaSigner.from_file(str(p)) + assert bytes(s.public_key) == _derive_pubkey(seed) + + def test_init_from_base58_secret_key(self, seed): + pub = _derive_pubkey(seed) + b58_secret = b58encode(seed + pub).decode() + s = SolanaSigner(b58_secret) + assert bytes(s.public_key) == pub + + def test_init_rejects_mismatched_pubkey(self, seed): + wrong_pub = bytes([0] * 32) + with pytest.raises(ValueError, match="does not match"): + SolanaSigner(seed + wrong_pub) + + def test_init_rejects_bad_length(self): + with pytest.raises(ValueError, match="Invalid Solana secret key length"): + SolanaSigner(bytes([0] * 33)) + + def test_init_rejects_bad_type(self): + with pytest.raises(TypeError): + SolanaSigner(12345) + + # --- signing ----------------------------------------------------------- + + def test_sign_basic(self, signer): + sig = signer.sign(bytearray(b"Hello, Solana!")) + assert isinstance(sig, bytearray) + assert len(sig) == 64 + + def test_sign_empty_message(self, signer): + sig = signer.sign(bytearray()) + assert len(sig) == 64 + + def test_sign_deterministic(self, seed): + msg = bytearray(b"deterministic") + assert SolanaSigner(seed).sign(msg) == SolanaSigner(seed).sign(msg) + + def test_sign_different_messages_differ(self, signer): + assert signer.sign(bytearray(b"a")) != signer.sign(bytearray(b"b")) + + def test_sign_verify_roundtrip(self, signer): + msg = bytearray(b"round trip me") + sig = signer.sign(msg) + assert SolanaSigner.verify(signer.public_key, msg, sig) is True + + def test_verify_rejects_tampered_message(self, signer): + sig = signer.sign(bytearray(b"original")) + assert SolanaSigner.verify(signer.public_key, bytearray(b"tampered"), sig) is False + + def test_verify_rejects_tampered_signature(self, signer): + msg = bytearray(b"original") + sig = signer.sign(msg) + sig[0] ^= 0xFF + assert SolanaSigner.verify(signer.public_key, msg, sig) is False + + def test_verify_with_garbage_returns_bool_false(self): + assert ( + SolanaSigner.verify(bytearray(b"\x00" * 32), bytearray(b"m"), bytearray(b"\x00" * 64)) + is False + ) + + # --- wallet address ---------------------------------------------------- + + def test_wallet_address_is_base58_of_pubkey(self, signer): + addr = signer.get_wallet_address() + assert addr == b58encode(bytes(signer.public_key)).decode() + # Round-trips back to the 32-byte public key. + assert b58decode(addr) == bytes(signer.public_key) + + def test_known_answer_rfc8032_pubkey(self, seed): + """Known-answer: RFC 8032 Test 1 seed derives the published public key.""" + s = SolanaSigner(seed) + assert bytes(s.public_key).hex() == RFC8032_TEST1_PUBLIC_HEX + + def test_known_answer_rfc8032_address(self, seed): + """Known-answer: the Solana address is base58(public key), pinned twice.""" + s = SolanaSigner(seed) + expected_addr = b58encode(bytes.fromhex(RFC8032_TEST1_PUBLIC_HEX)).decode() + assert s.get_wallet_address() == expected_addr + # Cross-check the base58 against an independent reference encoder. + assert s.get_wallet_address() == _ref_b58encode(bytes.fromhex(RFC8032_TEST1_PUBLIC_HEX)) + + def test_known_answer_rfc8032_signature(self, seed): + """Known-answer: signing the empty message yields the RFC 8032 signature.""" + s = SolanaSigner(seed) + sig = s.sign(bytearray()) + assert len(RFC8032_TEST1_SIG_HEX) == 128 # 64-byte signature as hex + assert bytes(sig).hex() == RFC8032_TEST1_SIG_HEX + assert SolanaSigner.verify(s.public_key, bytearray(), sig) is True + + def test_address_base58_crosscheck_independent_encoder(self, signer): + """Address from base58 lib must equal an independent reference encoder.""" + assert signer.get_wallet_address() == _ref_b58encode(bytes(signer.public_key)) + + def test_create_signed_headers(self, signer): + headers = signer.create_signed_headers() + assert set(headers) == {"x-signature", "x-nonce", "x-public-key"} + + +class TestSolanaDataItem: + """Full construct -> sign -> serialize -> parse -> verify loop for Solana.""" + + @pytest.fixture + def signer(self) -> SolanaSigner: + return SolanaSigner(bytes.fromhex(RFC8032_TEST1_SEED_HEX)) + + def test_dataitem_roundtrip(self, signer): + from turbo_sdk.bundle.create import create_data + from turbo_sdk.bundle.sign import sign, get_signature_data + from turbo_sdk.bundle.dataitem import DataItem + + data = bytearray(b"solana data item payload") + tags = [ + {"name": "Content-Type", "value": "application/octet-stream"}, + {"name": "App-Name", "value": "turbo-sdk-solana-test"}, + ] + + item = create_data(data, signer, tags=tags) + sign(item, signer) + + # Re-parse from the raw bytes exactly as a downstream reader would. + parsed = DataItem(bytearray(item.get_raw())) + + assert parsed.signature_type == 2 + assert len(parsed.raw_signature) == 64 + assert len(parsed.raw_owner) == 32 + assert bytes(parsed.raw_owner) == bytes(signer.public_key) + assert parsed.raw_data == data + assert parsed.get_tags_count() == 2 + assert parsed.is_valid() is True + + # The embedded ed25519 signature must verify against the embedded owner + # over the data item's deep-hash signing bytes. + signing_bytes = get_signature_data(parsed) + assert SolanaSigner.verify(parsed.raw_owner, signing_bytes, parsed.raw_signature) is True + + # Wrong key must NOT verify (negative control). + other = SolanaSigner(bytes([7] * 32)) + assert SolanaSigner.verify(other.public_key, signing_bytes, parsed.raw_signature) is False + + def test_dataitem_turbo_token_mapping(self, signer): + """Turbo() must accept a SolanaSigner and map it to the 'solana' token.""" + from turbo_sdk.client import Turbo + + turbo = Turbo(signer) + assert turbo.token == "solana" + + def test_dataitem_conformance_against_arbundles(self, signer): + """Cross-implementation conformance against @dha-team/arbundles 1.0.4. + + The pinned signing-data (deep hash) and signature below were produced + by arbundles' canonical ``SolanaSigner`` for the same fixed seed, tags, + data, and anchor, then confirmed byte-identical to this SDK's output. + arbundles is the library Turbo's upload backend validates with, so this + is the authoritative regression guard. It specifically locks in + signature type **2** (raw ED25519) โ€” labeling Solana as type 4 + (HexInjectedSolana) produces a different deep hash and is rejected by + the backend as "Invalid Data Item". + """ + from turbo_sdk.bundle.create import create_data + from turbo_sdk.bundle.sign import sign, get_signature_data + + anchor = bytes.fromhex( + "0102030405060708090a0b0c0d0e0f10" "1112131415161718191a1b1c1d1e1f20" + ) + # Cross-verified byte-for-byte against @dha-team/arbundles@1.0.4. + EXPECTED_SIGDATA = ( + "82d7633c0ac2ede1bd88934a588fd4b6d6eb966aaec40e4953cc1fd6" + "7958d4bf833e56e6b31bf33787c174ea51c3651a" + ) + EXPECTED_SIG = ( + "c6148e3cc59ac617268f7e194dc4e3d15e7a0c1b18e270ef902f74b6" + "be9d3f239b265f95483e37687783b01fbd979a69d3a2e69996865b6c" + "856ea5dca1488f01" + ) + + item = create_data( + bytearray(b"hello-solana"), + signer, + tags=[{"name": "App-Name", "value": "ario-solana-ref"}], + anchor=anchor, + ) + signing_bytes = get_signature_data(item) + sign(item, signer) + + assert item.signature_type == 2 + assert bytes(signing_bytes).hex() == EXPECTED_SIGDATA + assert bytes(item.raw_signature).hex() == EXPECTED_SIG diff --git a/turbo_sdk/__init__.py b/turbo_sdk/__init__.py index 6d45b47..4e80640 100644 --- a/turbo_sdk/__init__.py +++ b/turbo_sdk/__init__.py @@ -31,7 +31,7 @@ ProgressCallback, ChunkingMode, ) -from .signers import EthereumSigner, ArweaveSigner +from .signers import EthereumSigner, ArweaveSigner, SolanaSigner from .chunked import ( ChunkedUploader, ChunkedUploadError, @@ -63,4 +63,5 @@ # Signers "EthereumSigner", "ArweaveSigner", + "SolanaSigner", ] diff --git a/turbo_sdk/client.py b/turbo_sdk/client.py index 9bc8377..841a39e 100644 --- a/turbo_sdk/client.py +++ b/turbo_sdk/client.py @@ -31,6 +31,7 @@ class Turbo: # Map signature types to token names TOKEN_MAP = { 1: "arweave", # Arweave RSA-PSS + 2: "solana", # ED25519 raw โ€” used by SolanaSigner; Turbo bills as solana 3: "ethereum", # Ethereum ECDSA } diff --git a/turbo_sdk/signers/__init__.py b/turbo_sdk/signers/__init__.py index 7807560..6983655 100644 --- a/turbo_sdk/signers/__init__.py +++ b/turbo_sdk/signers/__init__.py @@ -1,4 +1,5 @@ from .ethereum import EthereumSigner from .arweave import ArweaveSigner +from .solana import SolanaSigner -__all__ = ["EthereumSigner", "ArweaveSigner"] +__all__ = ["EthereumSigner", "ArweaveSigner", "SolanaSigner"] diff --git a/turbo_sdk/signers/solana.py b/turbo_sdk/signers/solana.py new file mode 100644 index 0000000..db6d126 --- /dev/null +++ b/turbo_sdk/signers/solana.py @@ -0,0 +1,137 @@ +import json +from typing import Any, Union + +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) +from cryptography.hazmat.primitives import serialization +from cryptography.exceptions import InvalidSignature +from base58 import b58encode, b58decode + +from turbo_sdk.signers.signer import Signer +from turbo_sdk.bundle.constants import SIG_CONFIG + + +class SolanaSigner(Signer): + """ + Solana (ed25519) signer for ANS-104 data items. + + Solana accounts are ed25519 keypairs; the on-chain address is simply the + base58 encoding of the 32-byte ed25519 public key. This signer produces + signature_type 2 (raw ED25519) data items, which Turbo bills as the + ``solana`` token. + + Accepted secret-key formats (passed to the constructor): + + * **Solana CLI keypair** (``id.json``): a JSON array of 64 integers, being + the 32-byte ed25519 seed (private scalar source) followed by the 32-byte + public key. May be supplied as the parsed ``list``/``bytes`` itself or as + a path/JSON string (see ``from_file`` / the constructor's ``str`` branch). + * **Raw 64-byte secret key**: ``bytes``/``bytearray`` of seed(32) โ€– pub(32), + i.e. the same layout the CLI file decodes to. + * **Raw 32-byte seed**: ``bytes``/``bytearray`` of just the ed25519 seed; + the public key is derived from it. + * **Base58-encoded secret key** (``str``): a base58 string decoding to a + 32- or 64-byte secret key, the form exported by Phantom and other wallets. + + Only the 32-byte seed is cryptographically required; when a 64-byte key is + supplied the trailing public key is used to sanity-check the derivation and + a ``ValueError`` is raised on mismatch. + """ + + public_key = ( + None # set in __init__; shadows the base abstract property (matches EthereumSigner) + ) + # Solana data items use signature type 2 (raw ED25519 over the deep hash), + # NOT type 4. Type 4 is HexInjectedSolanaSigner โ€” a browser-injected-wallet + # variant that signs a hex-encoded message, which the backend validates + # differently. The canonical server-side Solana signer (arbundles + # SolanaSigner) is type 2; Turbo bills type-2 ed25519 items uploaded to + # /tx/solana as the solana token. + signature_type = 2 + signature_length = SIG_CONFIG[2]["sigLength"] # 64 + owner_length = SIG_CONFIG[2]["pubLength"] # 32 + + def __init__(self, secret_key: Union[bytes, bytearray, list, str]): + seed, embedded_pub = self._normalize_secret_key(secret_key) + + self._private_key = Ed25519PrivateKey.from_private_bytes(bytes(seed)) + derived_pub = self._private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + + if embedded_pub is not None and bytes(embedded_pub) != derived_pub: + raise ValueError( + "Solana secret key public-key half does not match the key " + "derived from the seed (corrupt or mismatched keypair)" + ) + + self.public_key = bytearray(derived_pub) + + @classmethod + def from_file(cls, path: str) -> "SolanaSigner": + """Load a Solana CLI ``id.json`` keypair file (JSON array of ints).""" + with open(path, "r") as f: + return cls(json.load(f)) + + @staticmethod + def _normalize_secret_key(secret_key): + """ + Return ``(seed32, embedded_pub_or_None)`` from any accepted input. + """ + # JSON array (Solana CLI id.json), already parsed or as a JSON string. + if isinstance(secret_key, str): + stripped = secret_key.strip() + if stripped.startswith("["): + secret_key = json.loads(stripped) + else: + # Base58-encoded secret key (Phantom export, etc.) + secret_key = b58decode(stripped) + + if isinstance(secret_key, list): + secret_key = bytes(secret_key) + + if isinstance(secret_key, (bytes, bytearray)): + raw = bytes(secret_key) + if len(raw) == 64: + return raw[:32], raw[32:] + if len(raw) == 32: + return raw, None + raise ValueError( + f"Invalid Solana secret key length: {len(raw)} " + "(expected 32-byte seed or 64-byte secret key)" + ) + + raise TypeError( + "Unsupported Solana secret key type: " + f"{type(secret_key).__name__} (expected bytes, list, or str)" + ) + + def sign(self, message: bytearray, **opts: Any) -> bytearray: + """ + Sign the raw message bytes with ed25519. + + Ed25519 hashes internally (SHA-512), so the message must be passed + through verbatim with no extra pre-hashing โ€” this matches the + arbundles / ANS-104 deep-hash signing flow. + """ + signature = self._private_key.sign(bytes(message)) + return bytearray(signature) + + @staticmethod + def verify(pubkey: bytearray, message: bytearray, signature: bytearray, **opts: Any) -> bool: + """Verify an ed25519 signature against a 32-byte public key.""" + try: + public_key = Ed25519PublicKey.from_public_bytes(bytes(pubkey)) + public_key.verify(bytes(signature), bytes(message)) + return True + except (InvalidSignature, ValueError): + return False + + def get_wallet_address(self) -> str: + """ + The Solana address is the base58 encoding of the 32-byte public key. + """ + return b58encode(bytes(self.public_key)).decode("utf-8")