commit 4a7d8b875a43c71146e57b0904162f155fc11c95 Author: Andrew Miller Date: Fri May 29 16:53:16 2026 -0400 Initial commit: Python SDK for Dragonchain (Prime) Synchronous Python SDK modeled on prime-sdk-go. Provides DC1-HMAC-SHA256 auth, dataclass models, and resource clients for system, transaction, transaction-type, smart-contract, and block endpoints, plus a YAML credentials loader. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..753803b --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +build/ +dist/ +*.egg-info/ +*.egg +.eggs/ + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Test / coverage +.pytest_cache/ +.coverage +coverage.xml +htmlcov/ +.tox/ + +# Type checkers / linters +.mypy_cache/ +.ruff_cache/ + +# IDEs +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Credentials and local config +credentials.yaml +credentials.yml +.env +.env.local +*.key +*.pem +*.p12 + +# OS specific +Thumbs.db +ehthumbs.db +._* diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..6451a48 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Dragonchain, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa9ecfd --- /dev/null +++ b/README.md @@ -0,0 +1,173 @@ +# Dragonchain Python SDK + +A self-contained Python SDK for interacting with Dragonchain (Prime) nodes. + +## Installation + +```bash +pip install prime-sdk-python +``` + +Or, from a local checkout: + +```bash +pip install -e . +``` + +Requires Python 3.9+. + +## Usage + +```python +from prime_sdk import DragonchainSDK, TransactionCreateRequest + +# Initialize the SDK +client = DragonchainSDK( + "your-public-id", + "your-auth-key-id", + "your-auth-key", + "https://your-dragonchain-endpoint.com", +) + +# Check system health (raises DragonchainAPIError if unhealthy) +client.system.health() + +# Get system status +status = client.system.status() +print(f"Chain ID: {status.id}, Level: {status.level}") + +# Create a transaction +resp = client.transaction.create( + TransactionCreateRequest( + txn_type="my-transaction-type", + payload='{"message": "Hello Dragonchain"}', + tag="example-tag", + ) +) +print(f"Created transaction: {resp.transaction_id}") +``` + +## Transaction Modes + +`create` and `create_bulk` return **immediately** with the assigned transaction +ID(s). They do **not** wait for block inclusion. Blocks are assembled +asynchronously on a ~5-second cycle. + +### Fire and Forget + +Most use cases only need the transaction ID. No polling required. + +```python +resp = client.transaction.create( + TransactionCreateRequest( + txn_type="my-transaction-type", + payload='{"message": "Hello Dragonchain"}', + ) +) +print("Transaction ID:", resp.transaction_id) +# Done — no need to wait for a block. +``` + +### Wait for Block + +If you need the block ID (e.g. for interchain verification or Daria), poll +`get` until `header.block_id` is populated. + +```python +import time + +resp = client.transaction.create( + TransactionCreateRequest( + txn_type="my-transaction-type", + payload='{"message": "Hello Dragonchain"}', + ) +) + +while True: + txn = client.transaction.get(resp.transaction_id) + if txn.header.block_id: + print("Block ID:", txn.header.block_id) + break + time.sleep(2) +``` + +## Loading credentials from a file + +The SDK can read chain credentials from a YAML file: + +```yaml +default: my-public-id +chains: + - name: my-chain + publicId: my-public-id + authKeyId: my-auth-key-id + authKey: my-auth-key + endpoint: https://chains.dragonchain.com +``` + +```python +from prime_sdk import DragonchainSDK +from prime_sdk.credentials import load_config + +cfg = load_config("~/.dragonchain/credentials.yaml") +chain = cfg.get_default_chain() + +client = DragonchainSDK( + chain.public_id, chain.auth_key_id, chain.auth_key, chain.endpoint +) +``` + +## Available Endpoints + +### System +- `system.health()` — Check system health +- `system.status()` — Get system status + +### Transaction +- `transaction.create(req)` — Create a new transaction +- `transaction.create_bulk(req)` — Create multiple transactions +- `transaction.get(transaction_id)` — Get transaction by ID +- `transaction.list()` — List all transactions + +### Transaction Type +- `transaction_type.create(req)` — Create a new transaction type +- `transaction_type.get(txn_type)` — Get transaction type by name +- `transaction_type.list()` — List all transaction types +- `transaction_type.delete(txn_type)` — Delete a transaction type + +### Smart Contract +- `contract.create(req)` — Create a new smart contract +- `contract.get(contract_id)` — Get smart contract by ID +- `contract.list()` — List all smart contracts +- `contract.update(contract_id, req)` — Update a smart contract +- `contract.upload(contract_id, filepath)` — Upload smart contract code +- `contract.delete(contract_id)` — Delete a smart contract + +### Block +- `block.get(block_id)` — Get block by ID + +## Authentication + +The SDK uses HMAC-SHA256 authentication. You need to provide: +- `public_id` — Your Dragonchain public ID +- `auth_key_id` — Your authentication key ID +- `auth_key` — Your authentication key +- `base_url` — The base URL of your Dragonchain node (e.g. "https://chains.dragonchain.com") + +## Error handling + +Any non-2xx response raises `prime_sdk.DragonchainAPIError`, which exposes +`.status_code` and `.body`: + +```python +from prime_sdk import DragonchainAPIError + +try: + client.transaction.get("nonexistent-id") +except DragonchainAPIError as e: + print(e.status_code, e.body) +``` + +## License + +Apache-2.0 diff --git a/prime_sdk/__init__.py b/prime_sdk/__init__.py new file mode 100644 index 0000000..23d5781 --- /dev/null +++ b/prime_sdk/__init__.py @@ -0,0 +1,145 @@ +"""Python SDK for interacting with Dragonchain (Prime) nodes. + +Example — fire and forget (most use cases):: + + from prime_sdk import DragonchainSDK, TransactionCreateRequest + + client = DragonchainSDK( + "your-public-id", + "your-auth-key-id", + "your-auth-key", + "https://your-dragonchain-endpoint.com", + ) + + resp = client.transaction.create( + TransactionCreateRequest( + txn_type="my-transaction-type", + payload='{"message": "Hello Dragonchain"}', + ) + ) + print("Transaction ID:", resp.transaction_id) + # Done — no need to wait for a block. + +Example — wait for block inclusion (interchain / Daria):: + + import time + + resp = client.transaction.create( + TransactionCreateRequest( + txn_type="my-transaction-type", + payload='{"message": "Hello Dragonchain"}', + ) + ) + + while True: + txn = client.transaction.get(resp.transaction_id) + if txn.header.block_id: + print("Block ID:", txn.header.block_id) + break + time.sleep(2) +""" + +from .block import BlockClient +from .client import CONTENT_TYPE_JSON, Client +from .contract import ContractClient +from .errors import DragonchainAPIError, DragonchainError +from .models import ( + Block, + BlockProof, + GrpcConnectionInfo, + ListResponse, + ListTransactionsResponse, + SmartContract, + SmartContractCreateRequest, + SmartContractExecutionInfo, + SmartContractUpdateRequest, + SuccessResponse, + SystemStatus, + Transaction, + TransactionBulkRequest, + TransactionBulkResponse, + TransactionCreateRequest, + TransactionCreateResponse, + TransactionHeader, + TransactionListResponse, + TransactionProof, + TransactionType, + TransactionTypeCreateRequest, + TransactionTypeCreateResponse, +) +from .system import SystemClient +from .transaction import TransactionClient +from .transaction_type import TransactionTypeClient + +__version__ = "0.1.0" + + +class DragonchainSDK: + """Main SDK client for interacting with Dragonchain nodes. + + Provides access to all API endpoints through specialized client instances: + ``system``, ``transaction``, ``transaction_type``, ``contract``, ``block``. + """ + + def __init__( + self, + public_id: str, + auth_key_id: str, + auth_key: str, + base_url: str, + ): + """Create a new Dragonchain SDK client. + + Args: + public_id: Your Dragonchain public ID. + auth_key_id: Your authentication key ID. + auth_key: Your authentication key. + base_url: Base URL of your node (e.g. "https://chains.dragonchain.com"). + """ + self._client = Client(public_id, auth_key_id, auth_key, base_url) + self.transaction = TransactionClient(self._client) + self.transaction_type = TransactionTypeClient(self._client) + self.contract = ContractClient(self._client) + self.block = BlockClient(self._client) + self.system = SystemClient(self._client) + + def get_client(self) -> Client: + """Return the underlying HTTP client (advanced use).""" + return self._client + + +__all__ = [ + "DragonchainSDK", + "Client", + "CONTENT_TYPE_JSON", + "DragonchainError", + "DragonchainAPIError", + "BlockClient", + "ContractClient", + "SystemClient", + "TransactionClient", + "TransactionTypeClient", + # models + "Block", + "BlockProof", + "GrpcConnectionInfo", + "ListResponse", + "ListTransactionsResponse", + "SmartContract", + "SmartContractCreateRequest", + "SmartContractExecutionInfo", + "SmartContractUpdateRequest", + "SuccessResponse", + "SystemStatus", + "Transaction", + "TransactionBulkRequest", + "TransactionBulkResponse", + "TransactionCreateRequest", + "TransactionCreateResponse", + "TransactionHeader", + "TransactionListResponse", + "TransactionProof", + "TransactionType", + "TransactionTypeCreateRequest", + "TransactionTypeCreateResponse", +] diff --git a/prime_sdk/block.py b/prime_sdk/block.py new file mode 100644 index 0000000..e0fddcb --- /dev/null +++ b/prime_sdk/block.py @@ -0,0 +1,12 @@ +"""Block endpoints.""" + +from .client import Client +from .models import Block + + +class BlockClient: + def __init__(self, client: Client): + self._client = client + + def get(self, block_id: str) -> Block: + return self._client.get(f"/api/v1/block/{block_id}", Block) diff --git a/prime_sdk/client.py b/prime_sdk/client.py new file mode 100644 index 0000000..017b31c --- /dev/null +++ b/prime_sdk/client.py @@ -0,0 +1,167 @@ +"""Low-level HTTP client implementing Dragonchain's DC1-HMAC-SHA256 auth. + +This is a direct port of the Go SDK's ``client/client.go``. The signing scheme +must match the prime-node authorizer byte-for-byte: the signed message is six +fields joined by ``\\n`` (no trailing newline) in this exact order:: + + UPPER(method) + path # full path incl. /api/v1/..., no query string + publicId + timestamp + contentType + base64(sha256(body)) +""" + +import base64 +import hashlib +import hmac +import json +import time +from typing import Any, Optional, Type, TypeVar + +import requests + +from .errors import DragonchainAPIError + +T = TypeVar("T") + +CONTENT_TYPE_JSON = "application/json" +DEFAULT_TIMEOUT = 30 # seconds + + +class Client: + """Authenticated HTTP client for a single Dragonchain node.""" + + def __init__( + self, + public_id: str, + auth_key_id: str, + auth_key: str, + base_url: str, + timeout: int = DEFAULT_TIMEOUT, + ): + self.public_id = public_id + self.auth_key_id = auth_key_id + self.auth_key = auth_key + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self._session = requests.Session() + + # -- introspection helpers (mirror the Go accessors) -------------------- + + def internal_id(self) -> str: + return self.public_id + + def get_public_id(self) -> str: + return self.public_id + + def get_auth_key_id(self) -> str: + return self.auth_key_id + + def endpoint(self) -> str: + return self.base_url + + # -- auth --------------------------------------------------------------- + + def _hmac_message( + self, method: str, path: str, timestamp: str, content_type: str, body: bytes + ) -> str: + b64_content = base64.b64encode(hashlib.sha256(body).digest()).decode("ascii") + return "\n".join( + [ + method.upper(), + path, + self.public_id, + timestamp, + content_type, + b64_content, + ] + ) + + def _generate_auth_header( + self, method: str, path: str, timestamp: str, content_type: str, body: bytes + ) -> str: + msg = self._hmac_message(method, path, timestamp, content_type, body) + digest = hmac.new( + self.auth_key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256 + ).digest() + b64_hmac = base64.b64encode(digest).decode("ascii") + return f"DC1-HMAC-SHA256 {self.auth_key_id}:{b64_hmac}" + + # -- request plumbing --------------------------------------------------- + + def _do_request( + self, + method: str, + path: str, + content_type: str = "", + body: Any = None, + response_cls: Optional[Type[T]] = None, + ) -> Optional[T]: + if isinstance(body, (bytes, bytearray)): + body_bytes = bytes(body) + elif body is not None: + body_bytes = json.dumps(_to_jsonable(body)).encode("utf-8") + content_type = CONTENT_TYPE_JSON + else: + body_bytes = b"" + + timestamp = str(int(time.time())) + auth_header = self._generate_auth_header( + method, path, timestamp, content_type, body_bytes + ) + + headers = { + "Authorization": auth_header, + "Dragonchain": self.public_id, + "Timestamp": timestamp, + } + if content_type: + headers["Content-Type"] = content_type + + url = self.base_url + path + resp = self._session.request( + method, url, data=body_bytes, headers=headers, timeout=self.timeout + ) + + resp_text = resp.text + if resp.status_code >= 400: + raise DragonchainAPIError(resp.status_code, resp_text.strip()) + + if response_cls is not None and resp_text: + parsed = json.loads(resp_text) + return response_cls.from_dict(parsed) # type: ignore[attr-defined] + return None + + # -- verb helpers ------------------------------------------------------- + + def get(self, path: str, response_cls: Optional[Type[T]] = None) -> Optional[T]: + return self._do_request("GET", path, response_cls=response_cls) + + def post( + self, + path: str, + content_type: str, + body: Any, + response_cls: Optional[Type[T]] = None, + ) -> Optional[T]: + return self._do_request("POST", path, content_type, body, response_cls) + + def put( + self, + path: str, + content_type: str, + body: Any, + response_cls: Optional[Type[T]] = None, + ) -> Optional[T]: + return self._do_request("PUT", path, content_type, body, response_cls) + + def delete(self, path: str, response_cls: Optional[Type[T]] = None) -> Optional[T]: + return self._do_request("DELETE", path, response_cls=response_cls) + + +def _to_jsonable(body: Any) -> Any: + """Convert request models (which expose ``to_dict``) to JSON-ready data.""" + if hasattr(body, "to_dict"): + return body.to_dict() + return body diff --git a/prime_sdk/contract.py b/prime_sdk/contract.py new file mode 100644 index 0000000..50f5d71 --- /dev/null +++ b/prime_sdk/contract.py @@ -0,0 +1,49 @@ +"""Smart-contract endpoints.""" + +from .client import CONTENT_TYPE_JSON, Client +from .models import ( + ListResponse, + SmartContract, + SmartContractCreateRequest, + SmartContractUpdateRequest, + SuccessResponse, +) + + +class ContractClient: + def __init__(self, client: Client): + self._client = client + + def create(self, req: SmartContractCreateRequest) -> SmartContract: + return self._client.post( + "/api/v1/contract", CONTENT_TYPE_JSON, req, SmartContract + ) + + def get(self, contract_id: str) -> SmartContract: + return self._client.get(f"/api/v1/contract/{contract_id}", SmartContract) + + def list(self) -> ListResponse: + return self._client.get("/api/v1/contract", ListResponse) + + def update( + self, contract_id: str, req: SmartContractUpdateRequest + ) -> SuccessResponse: + return self._client.put( + f"/api/v1/contract/{contract_id}", CONTENT_TYPE_JSON, req, SuccessResponse + ) + + def upload(self, contract_id: str, filepath: str) -> SuccessResponse: + """Upload smart-contract code from a local file (raw octet-stream body).""" + with open(filepath, "rb") as f: + file_content = f.read() + return self._client.put( + f"/api/v1/contract/{contract_id}/upload", + "application/octet-stream", + file_content, + SuccessResponse, + ) + + def delete(self, contract_id: str) -> SuccessResponse: + return self._client.delete( + f"/api/v1/contract/{contract_id}", SuccessResponse + ) diff --git a/prime_sdk/credentials.py b/prime_sdk/credentials.py new file mode 100644 index 0000000..e4b91cc --- /dev/null +++ b/prime_sdk/credentials.py @@ -0,0 +1,127 @@ +"""YAML-based chain credentials, ported from ``credentials/credentials.go``. + +The YAML format mirrors the Go SDK:: + + default: my-public-id + chains: + - name: my-chain + publicId: my-public-id + authKeyId: my-auth-key-id + authKey: my-auth-key + endpoint: https://chains.dragonchain.com + +Note: unlike the Go version (which uses yaml.Node to preserve comments and +blank-line formatting), this loader uses PyYAML and does not round-trip +comments on ``save_config``. +""" + +import os +from dataclasses import dataclass, field +from typing import List, Optional + +import yaml + + +@dataclass +class ChainConfig: + name: str = "" + public_id: str = "" + auth_key_id: str = "" + auth_key: str = "" + endpoint: str = "" + + @classmethod + def from_dict(cls, d: dict) -> "ChainConfig": + return cls( + name=d.get("name", ""), + public_id=d.get("publicId", ""), + auth_key_id=d.get("authKeyId", ""), + auth_key=d.get("authKey", ""), + endpoint=d.get("endpoint", ""), + ) + + def to_dict(self) -> dict: + return { + "name": self.name, + "publicId": self.public_id, + "authKeyId": self.auth_key_id, + "authKey": self.auth_key, + "endpoint": self.endpoint, + } + + +@dataclass +class Config: + default: str = "" + chains: List[ChainConfig] = field(default_factory=list) + + # -- factory helpers ---------------------------------------------------- + + @classmethod + def from_dict(cls, d: dict) -> "Config": + return cls( + default=d.get("default", ""), + chains=[ChainConfig.from_dict(c) for c in (d.get("chains") or [])], + ) + + def to_dict(self) -> dict: + return { + "default": self.default, + "chains": [c.to_dict() for c in self.chains], + } + + # -- lookups ------------------------------------------------------------ + + def get_default_chain(self) -> Optional[ChainConfig]: + """Return the chain configuration for the default publicId, or None.""" + return self.get_chain_by_public_id(self.default) + + def get_chain_by_public_id(self, public_id: str) -> Optional[ChainConfig]: + for chain in self.chains: + if chain.public_id == public_id: + return chain + return None + + def list_chains(self) -> List[str]: + """Return all chain names.""" + return [c.name for c in self.chains] + + # -- mutations ---------------------------------------------------------- + + def add_chain(self, chain: ChainConfig) -> None: + self.chains.append(chain) + + def set_default(self, public_id: str) -> None: + self.default = public_id + + def delete_chain(self, public_id: str) -> None: + """Delete a chain by publicId. Raises ValueError if it's the default.""" + if public_id == self.default: + raise ValueError("cannot delete default chain") + self.chains = [c for c in self.chains if c.public_id != public_id] + + # -- persistence -------------------------------------------------------- + + def save_config(self, file_path: str) -> None: + expanded = expand_path(file_path) + with open(expanded, "w", encoding="utf-8") as f: + yaml.safe_dump(self.to_dict(), f, sort_keys=False, default_flow_style=False) + + +def load_config(file_path: str) -> Config: + """Read and parse a YAML configuration file.""" + expanded = expand_path(file_path) + with open(expanded, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + return Config.from_dict(data) + + +def load_config_from_string(yaml_content: str) -> Config: + """Parse YAML configuration from a string.""" + data = yaml.safe_load(yaml_content) or {} + return Config.from_dict(data) + + +def expand_path(path: str) -> str: + """Expand environment variables and a leading ``~`` in a path.""" + return os.path.expanduser(os.path.expandvars(path)) diff --git a/prime_sdk/errors.py b/prime_sdk/errors.py new file mode 100644 index 0000000..49465ad --- /dev/null +++ b/prime_sdk/errors.py @@ -0,0 +1,19 @@ +"""Exceptions raised by the Dragonchain SDK.""" + + +class DragonchainError(Exception): + """Base class for all errors raised by the SDK.""" + + +class DragonchainAPIError(DragonchainError): + """Raised when the Dragonchain node returns an HTTP error (status >= 400). + + Attributes: + status_code: The HTTP status code returned by the node. + body: The (trimmed) response body returned by the node. + """ + + def __init__(self, status_code: int, body: str): + self.status_code = status_code + self.body = body + super().__init__(f"API error (status {status_code}): {body}") diff --git a/prime_sdk/models.py b/prime_sdk/models.py new file mode 100644 index 0000000..db81754 --- /dev/null +++ b/prime_sdk/models.py @@ -0,0 +1,395 @@ +"""Request and response models, ported from the Go SDK's ``models/models.go``. + +Request models expose ``to_dict()`` which emits the exact JSON keys the node +expects and drops empty/optional values (the Go ``omitempty`` equivalent). +Response models expose a ``from_dict()`` classmethod that reads the exact JSON +keys returned by the node (snake_case for transaction/block models, camelCase +for contract/status models — preserved verbatim from the Go struct tags). +""" + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +CONTENT_TYPE_JSON = "application/json" + + +# --------------------------------------------------------------------------- # +# Transactions +# --------------------------------------------------------------------------- # + + +@dataclass +class TransactionCreateRequest: + txn_type: str + payload: str + tag: Optional[str] = None + version: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + d: Dict[str, Any] = {"txn_type": self.txn_type, "payload": self.payload} + if self.version: + d["version"] = self.version + if self.tag: + d["tag"] = self.tag + return d + + +@dataclass +class TransactionCreateResponse: + transaction_id: str = "" + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "TransactionCreateResponse": + return cls(transaction_id=d.get("transaction_id", "")) + + +@dataclass +class TransactionBulkRequest: + transactions: List[TransactionCreateRequest] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return {"transactions": [t.to_dict() for t in self.transactions]} + + +@dataclass +class TransactionBulkResponse: + transaction_ids: List[str] = field(default_factory=list) + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "TransactionBulkResponse": + return cls(transaction_ids=d.get("transaction_ids") or []) + + +@dataclass +class TransactionHeader: + tag: str = "" + dc_id: str = "" + txn_id: str = "" + invoker: str = "" + block_id: str = "" + txn_type: str = "" + timestamp: str = "" + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "TransactionHeader": + return cls( + tag=d.get("tag", ""), + dc_id=d.get("dc_id", ""), + txn_id=d.get("txn_id", ""), + invoker=d.get("invoker", ""), + block_id=d.get("block_id", ""), + txn_type=d.get("txn_type", ""), + timestamp=d.get("timestamp", ""), + ) + + +@dataclass +class TransactionProof: + full: str = "" + stripped: str = "" + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "TransactionProof": + return cls(full=d.get("full", ""), stripped=d.get("stripped", "")) + + +@dataclass +class Transaction: + version: str = "" + header: TransactionHeader = field(default_factory=TransactionHeader) + proof: TransactionProof = field(default_factory=TransactionProof) + payload: str = "" + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "Transaction": + return cls( + version=d.get("version", ""), + header=TransactionHeader.from_dict(d.get("header") or {}), + proof=TransactionProof.from_dict(d.get("proof") or {}), + payload=d.get("payload", ""), + ) + + +@dataclass +class ListTransactionsResponse: + transactions: List[Transaction] = field(default_factory=list) + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "ListTransactionsResponse": + return cls( + transactions=[Transaction.from_dict(t) for t in (d.get("transactions") or [])] + ) + + +# --------------------------------------------------------------------------- # +# Transaction types +# --------------------------------------------------------------------------- # + + +@dataclass +class TransactionTypeCreateRequest: + txn_type: str + version: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + d: Dict[str, Any] = {"txn_type": self.txn_type} + if self.version: + d["version"] = self.version + return d + + +@dataclass +class TransactionTypeCreateResponse: + success: bool = False + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "TransactionTypeCreateResponse": + return cls(success=bool(d.get("success", False))) + + +@dataclass +class TransactionType: + version: str = "" + created: int = 0 + modified: int = 0 + txn_type: str = "" + contract_id: str = "" + custom_indexes: List[Any] = field(default_factory=list) + active_since_block: str = "" + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "TransactionType": + return cls( + version=d.get("version", ""), + created=d.get("created", 0), + modified=d.get("modified", 0), + txn_type=d.get("txn_type", ""), + contract_id=d.get("contract_id", ""), + custom_indexes=d.get("custom_indexes") or [], + active_since_block=d.get("active_since_block", ""), + ) + + +@dataclass +class TransactionListResponse: + """Response for listing transaction types (key: ``transactionTypes``).""" + + transaction_types: List[TransactionType] = field(default_factory=list) + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "TransactionListResponse": + return cls( + transaction_types=[ + TransactionType.from_dict(t) for t in (d.get("transactionTypes") or []) + ] + ) + + +# --------------------------------------------------------------------------- # +# Smart contracts +# --------------------------------------------------------------------------- # + + +@dataclass +class SmartContractCreateRequest: + environment: str + transaction_type: str + execution_order: str + environment_variables: Optional[Dict[str, str]] = None + secrets: Optional[Dict[str, str]] = None + remote: bool = False + + def to_dict(self) -> Dict[str, Any]: + d: Dict[str, Any] = { + "environment": self.environment, + "transactionType": self.transaction_type, + "executionOrder": self.execution_order, + } + if self.environment_variables: + d["environmentVariables"] = self.environment_variables + if self.secrets: + d["secret"] = self.secrets + if self.remote: + d["remote"] = self.remote + return d + + +@dataclass +class SmartContractUpdateRequest: + enabled: bool = False + environment_variables: Optional[Dict[str, str]] = None + secrets: Optional[Dict[str, str]] = None + version: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + d: Dict[str, Any] = {"enabled": self.enabled} + if self.version: + d["version"] = self.version + if self.environment_variables: + d["environmentVariables"] = self.environment_variables + if self.secrets: + d["secret"] = self.secrets + return d + + +@dataclass +class GrpcConnectionInfo: + address: str = "" + api_key: str = "" + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "GrpcConnectionInfo": + return cls(address=d.get("address", ""), api_key=d.get("apiKey", "")) + + +@dataclass +class SmartContractExecutionInfo: + type: str = "" + executable_path: str = "" + executable_working_directory: str = "" + executable_hash: str = "" + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "SmartContractExecutionInfo": + return cls( + type=d.get("type", ""), + executable_path=d.get("executablePath", ""), + executable_working_directory=d.get("executableWorkingDirectory", ""), + executable_hash=d.get("executableHash", ""), + ) + + +@dataclass +class SmartContract: + id: str = "" + created: int = 0 + modified: int = 0 + version: str = "" + environment: str = "" + transaction_type: str = "" + execution_order: str = "" + execution_info: Optional[SmartContractExecutionInfo] = None + env_vars: Dict[str, str] = field(default_factory=dict) + secrets: List[str] = field(default_factory=list) + grpc_connection_info: Optional[GrpcConnectionInfo] = None + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "SmartContract": + exec_info = d.get("executionInfo") + grpc = d.get("grpcConnectionInfo") + return cls( + id=d.get("id", ""), + created=d.get("created", 0), + modified=d.get("modified", 0), + version=d.get("version", ""), + environment=d.get("environment", ""), + transaction_type=d.get("transactionType", ""), + execution_order=d.get("executionOrder", ""), + execution_info=( + SmartContractExecutionInfo.from_dict(exec_info) if exec_info else None + ), + env_vars=d.get("envVars") or {}, + secrets=d.get("secrets") or [], + grpc_connection_info=(GrpcConnectionInfo.from_dict(grpc) if grpc else None), + ) + + +# --------------------------------------------------------------------------- # +# Blocks +# --------------------------------------------------------------------------- # + + +@dataclass +class BlockProof: + scheme: str = "" + proof: str = "" + nonce: int = 0 + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "BlockProof": + return cls( + scheme=d.get("scheme", ""), + proof=d.get("proof", ""), + nonce=d.get("nonce", 0), + ) + + +@dataclass +class Block: + version: str = "" + id: str = "" + timestamp: str = "" + prev_id: str = "" + prev_proof: str = "" + transactions: List[str] = field(default_factory=list) + proof: BlockProof = field(default_factory=BlockProof) + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "Block": + return cls( + version=d.get("version", ""), + id=d.get("block_id", ""), + timestamp=d.get("timestamp", ""), + prev_id=d.get("prev_id", ""), + prev_proof=d.get("prev_proof", ""), + transactions=d.get("transactions") or [], + proof=BlockProof.from_dict(d.get("proof") or {}), + ) + + +# --------------------------------------------------------------------------- # +# System / generic +# --------------------------------------------------------------------------- # + + +@dataclass +class SystemStatus: + id: str = "" + level: int = 0 + url: str = "" + hash_algo: str = "" + scheme: str = "" + version: str = "" + encryption_algo: str = "" + indexing_enabled: bool = False + # Level 5 node fields + funded: str = "" + broadcast_interval: str = "" + network: str = "" + interchain_wallet: str = "" + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "SystemStatus": + return cls( + id=d.get("id", ""), + level=d.get("level", 0), + url=d.get("url", ""), + hash_algo=d.get("hashAlgo", ""), + scheme=d.get("scheme", ""), + version=d.get("version", ""), + encryption_algo=d.get("encryptionAlgo", ""), + indexing_enabled=bool(d.get("indexingEnabled", False)), + funded=d.get("funded", ""), + broadcast_interval=d.get("broadcastInterval", ""), + network=d.get("network", ""), + interchain_wallet=d.get("interchainWallet", ""), + ) + + +@dataclass +class SuccessResponse: + success: bool = False + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "SuccessResponse": + return cls(success=bool(d.get("success", False))) + + +@dataclass +class ListResponse: + items: List[Any] = field(default_factory=list) + total_count: int = 0 + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "ListResponse": + return cls(items=d.get("items") or [], total_count=d.get("total_count", 0)) diff --git a/prime_sdk/system.py b/prime_sdk/system.py new file mode 100644 index 0000000..34c89d9 --- /dev/null +++ b/prime_sdk/system.py @@ -0,0 +1,17 @@ +"""System endpoints (health, status).""" + +from .client import Client +from .models import SystemStatus + + +class SystemClient: + def __init__(self, client: Client): + self._client = client + + def health(self) -> None: + """Check system health. Raises ``DragonchainAPIError`` if unhealthy.""" + self._client.get("/api/v1/health") + + def status(self) -> SystemStatus: + """Get system status.""" + return self._client.get("/api/v1/status", SystemStatus) diff --git a/prime_sdk/transaction.py b/prime_sdk/transaction.py new file mode 100644 index 0000000..89010a1 --- /dev/null +++ b/prime_sdk/transaction.py @@ -0,0 +1,43 @@ +"""Transaction endpoints.""" + +from .client import CONTENT_TYPE_JSON, Client +from .models import ( + ListTransactionsResponse, + Transaction, + TransactionBulkRequest, + TransactionBulkResponse, + TransactionCreateRequest, + TransactionCreateResponse, +) + + +class TransactionClient: + def __init__(self, client: Client): + self._client = client + + def create(self, req: TransactionCreateRequest) -> TransactionCreateResponse: + """Submit a new transaction and return immediately with the assigned ID. + + This does NOT wait for the transaction to be included in a block. Block + processing happens asynchronously on a ~5-second cycle. If you need the + block ID (e.g. for interchain verification), poll ``get`` until + ``header.block_id`` is populated. + """ + return self._client.post( + "/api/v1/transaction", CONTENT_TYPE_JSON, req, TransactionCreateResponse + ) + + def create_bulk(self, req: TransactionBulkRequest) -> TransactionBulkResponse: + """Submit multiple transactions and return immediately with their IDs. + + Like ``create``, this does not wait for block inclusion. + """ + return self._client.post( + "/api/v1/transaction/bulk", CONTENT_TYPE_JSON, req, TransactionBulkResponse + ) + + def get(self, transaction_id: str) -> Transaction: + return self._client.get(f"/api/v1/transaction/{transaction_id}", Transaction) + + def list(self) -> ListTransactionsResponse: + return self._client.get("/api/v1/transaction/", ListTransactionsResponse) diff --git a/prime_sdk/transaction_type.py b/prime_sdk/transaction_type.py new file mode 100644 index 0000000..15eb317 --- /dev/null +++ b/prime_sdk/transaction_type.py @@ -0,0 +1,38 @@ +"""Transaction-type endpoints.""" + +from .client import CONTENT_TYPE_JSON, Client +from .models import ( + SuccessResponse, + TransactionListResponse, + TransactionType, + TransactionTypeCreateRequest, + TransactionTypeCreateResponse, +) + + +class TransactionTypeClient: + def __init__(self, client: Client): + self._client = client + + def create( + self, req: TransactionTypeCreateRequest + ) -> TransactionTypeCreateResponse: + return self._client.post( + "/api/v1/transaction-type", + CONTENT_TYPE_JSON, + req, + TransactionTypeCreateResponse, + ) + + def get(self, txn_type: str) -> TransactionType: + return self._client.get( + f"/api/v1/transaction-type/{txn_type}", TransactionType + ) + + def list(self) -> TransactionListResponse: + return self._client.get("/api/v1/transaction-types", TransactionListResponse) + + def delete(self, txn_type: str) -> SuccessResponse: + return self._client.delete( + f"/api/v1/transaction-type/{txn_type}", SuccessResponse + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..83853b9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "prime-sdk-python" +version = "0.1.0" +description = "A self-contained Python SDK for interacting with Dragonchain nodes." +readme = "README.md" +requires-python = ">=3.9" +license = { text = "Apache-2.0" } +authors = [{ name = "Dragonchain" }] +keywords = ["dragonchain", "blockchain", "sdk", "prime"] +dependencies = [ + "requests>=2.25", + "PyYAML>=5.4", +] + +[project.urls] +Homepage = "https://git.dragonchain.com/dragonchain/prime-sdk-python" +Repository = "https://git.dragonchain.com/dragonchain/prime-sdk-python" + +[project.optional-dependencies] +dev = ["pytest>=7.0"] + +[tool.hatch.build.targets.wheel] +packages = ["prime_sdk"] diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..afbd328 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,223 @@ +"""Offline tests for the Dragonchain Python SDK. + +No network access required. The HMAC test independently recomputes the expected +Authorization header to guard the byte-for-byte contract with the prime-node +authorizer. +""" + +import base64 +import hashlib +import hmac +import json + +import pytest + +from prime_sdk import ( + DragonchainAPIError, + DragonchainSDK, + SmartContractCreateRequest, + Transaction, + TransactionCreateRequest, +) +from prime_sdk.client import Client +from prime_sdk.credentials import load_config_from_string + +PUBLIC_ID = "test-public-id" +AUTH_KEY_ID = "test-key-id" +AUTH_KEY = "test-secret-key" +BASE_URL = "https://chains.dragonchain.com" + + +def _expected_auth_header(method, path, timestamp, content_type, body: bytes) -> str: + """Recompute the expected header from scratch, independent of the SDK.""" + b64_content = base64.b64encode(hashlib.sha256(body).digest()).decode() + message = "\n".join([method.upper(), path, PUBLIC_ID, timestamp, content_type, b64_content]) + digest = hmac.new(AUTH_KEY.encode(), message.encode(), hashlib.sha256).digest() + return f"DC1-HMAC-SHA256 {AUTH_KEY_ID}:{base64.b64encode(digest).decode()}" + + +# --------------------------------------------------------------------------- # +# HMAC signing +# --------------------------------------------------------------------------- # + + +def test_hmac_message_format(): + c = Client(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL) + msg = c._hmac_message("post", "/api/v1/transaction", "1700000000", "application/json", b"hello") + lines = msg.split("\n") + assert len(lines) == 6 + assert lines[0] == "POST" # method uppercased + assert lines[1] == "/api/v1/transaction" + assert lines[2] == PUBLIC_ID + assert lines[3] == "1700000000" + assert lines[4] == "application/json" + assert lines[5] == base64.b64encode(hashlib.sha256(b"hello").digest()).decode() + assert not msg.endswith("\n") # no trailing newline + + +def test_auth_header_matches_independent_computation(): + c = Client(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL) + body = b'{"txn_type":"x","payload":"y"}' + got = c._generate_auth_header("POST", "/api/v1/transaction", "1700000000", "application/json", body) + want = _expected_auth_header("POST", "/api/v1/transaction", "1700000000", "application/json", body) + assert got == want + + +def test_empty_body_signs_sha256_of_empty(): + c = Client(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL) + got = c._generate_auth_header("GET", "/api/v1/status", "1700000000", "", b"") + want = _expected_auth_header("GET", "/api/v1/status", "1700000000", "", b"") + assert got == want + + +def test_base_url_trailing_slash_stripped(): + c = Client(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL + "/") + assert c.endpoint() == BASE_URL + + +# --------------------------------------------------------------------------- # +# Request plumbing (with a fake session) +# --------------------------------------------------------------------------- # + + +class _FakeResponse: + def __init__(self, status_code, text): + self.status_code = status_code + self.text = text + + +class _FakeSession: + def __init__(self, response): + self.response = response + self.calls = [] + + def request(self, method, url, data=None, headers=None, timeout=None): + self.calls.append({"method": method, "url": url, "data": data, "headers": headers}) + return self.response + + +def test_create_sends_correct_request_and_parses_response(): + sdk = DragonchainSDK(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL) + fake = _FakeSession(_FakeResponse(200, json.dumps({"transaction_id": "txn-123"}))) + sdk.get_client()._session = fake + + resp = sdk.transaction.create( + TransactionCreateRequest(txn_type="my-type", payload='{"a":1}', tag="t") + ) + + assert resp.transaction_id == "txn-123" + call = fake.calls[0] + assert call["method"] == "POST" + assert call["url"] == BASE_URL + "/api/v1/transaction" + # headers present and consistent + headers = call["headers"] + assert headers["Dragonchain"] == PUBLIC_ID + assert headers["Content-Type"] == "application/json" + assert headers["Authorization"].startswith(f"DC1-HMAC-SHA256 {AUTH_KEY_ID}:") + # the bytes sent are exactly the bytes that were signed + sent = call["data"] + expected_auth = _expected_auth_header( + "POST", "/api/v1/transaction", headers["Timestamp"], "application/json", sent + ) + assert headers["Authorization"] == expected_auth + # omitempty: version dropped, tag kept + body = json.loads(sent) + assert body == {"txn_type": "my-type", "payload": '{"a":1}', "tag": "t"} + + +def test_error_status_raises(): + sdk = DragonchainSDK(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL) + sdk.get_client()._session = _FakeSession(_FakeResponse(404, " not found ")) + with pytest.raises(DragonchainAPIError) as exc: + sdk.transaction.get("missing") + assert exc.value.status_code == 404 + assert exc.value.body == "not found" + + +def test_get_sends_no_content_type_header(): + sdk = DragonchainSDK(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL) + fake = _FakeSession(_FakeResponse(200, json.dumps({"id": "chain", "level": 1}))) + sdk.get_client()._session = fake + sdk.system.status() + headers = fake.calls[0]["headers"] + assert "Content-Type" not in headers + assert fake.calls[0]["data"] == b"" + + +# --------------------------------------------------------------------------- # +# Model (de)serialization +# --------------------------------------------------------------------------- # + + +def test_transaction_from_dict_nested(): + raw = { + "version": "1", + "header": {"txn_id": "abc", "block_id": "42", "txn_type": "t"}, + "proof": {"full": "ff", "stripped": "ss"}, + "payload": "{}", + } + txn = Transaction.from_dict(raw) + assert txn.header.txn_id == "abc" + assert txn.header.block_id == "42" + assert txn.proof.full == "ff" + assert txn.payload == "{}" + + +def test_contract_create_request_omitempty(): + req = SmartContractCreateRequest( + environment="python3.8", + transaction_type="my-type", + execution_order="parallel", + ) + d = req.to_dict() + assert d == { + "environment": "python3.8", + "transactionType": "my-type", + "executionOrder": "parallel", + } + # remote False is dropped; envvars/secrets absent + assert "remote" not in d + assert "environmentVariables" not in d + assert "secret" not in d + + +def test_contract_create_request_with_optionals(): + req = SmartContractCreateRequest( + environment="python3.8", + transaction_type="my-type", + execution_order="serial", + environment_variables={"K": "V"}, + secrets={"S": "v"}, + remote=True, + ) + d = req.to_dict() + assert d["environmentVariables"] == {"K": "V"} + assert d["secret"] == {"S": "v"} + assert d["remote"] is True + + +def test_credentials_round_trip(): + yaml_text = """ +default: pub-1 +chains: + - name: alpha + publicId: pub-1 + authKeyId: kid-1 + authKey: secret-1 + endpoint: https://chains.dragonchain.com + - name: beta + publicId: pub-2 + authKeyId: kid-2 + authKey: secret-2 + endpoint: https://dev-chains.dragonchain.com +""" + cfg = load_config_from_string(yaml_text) + assert cfg.default == "pub-1" + assert cfg.list_chains() == ["alpha", "beta"] + default = cfg.get_default_chain() + assert default is not None and default.auth_key_id == "kid-1" + assert cfg.get_chain_by_public_id("pub-2").endpoint.endswith("dev-chains.dragonchain.com") + with pytest.raises(ValueError): + cfg.delete_chain("pub-1") # default + cfg.delete_chain("pub-2") + assert cfg.list_chains() == ["alpha"]