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.
This commit is contained in:
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -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
|
||||
._*
|
||||
201
LICENSE
Executable file
201
LICENSE
Executable file
@@ -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.
|
||||
173
README.md
Normal file
173
README.md
Normal file
@@ -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
|
||||
145
prime_sdk/__init__.py
Normal file
145
prime_sdk/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
12
prime_sdk/block.py
Normal file
12
prime_sdk/block.py
Normal file
@@ -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)
|
||||
167
prime_sdk/client.py
Normal file
167
prime_sdk/client.py
Normal file
@@ -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
|
||||
49
prime_sdk/contract.py
Normal file
49
prime_sdk/contract.py
Normal file
@@ -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
|
||||
)
|
||||
127
prime_sdk/credentials.py
Normal file
127
prime_sdk/credentials.py
Normal file
@@ -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))
|
||||
19
prime_sdk/errors.py
Normal file
19
prime_sdk/errors.py
Normal file
@@ -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}")
|
||||
395
prime_sdk/models.py
Normal file
395
prime_sdk/models.py
Normal file
@@ -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))
|
||||
17
prime_sdk/system.py
Normal file
17
prime_sdk/system.py
Normal file
@@ -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)
|
||||
43
prime_sdk/transaction.py
Normal file
43
prime_sdk/transaction.py
Normal file
@@ -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)
|
||||
38
prime_sdk/transaction_type.py
Normal file
38
prime_sdk/transaction_type.py
Normal file
@@ -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
|
||||
)
|
||||
27
pyproject.toml
Normal file
27
pyproject.toml
Normal file
@@ -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"]
|
||||
223
tests/test_client.py
Normal file
223
tests/test_client.py
Normal file
@@ -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"]
|
||||
Reference in New Issue
Block a user