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