Compare commits

...

14 Commits
v0.1.3 ... main

Author SHA1 Message Date
77216001e5 🤖 Bumped version to 0.1.5
All checks were successful
Package Python / Package (push) Successful in 24s
Run Python tests (through Pytest) / Test (push) Successful in 24s
Verify Python project can be installed, loaded and have version checked / Test (push) Successful in 23s
This commit was automatically generated by [a script](https://gitfub.space/Jmaa/repo-manager)
2025-04-13 18:47:12 +02:00
20a2f7a672 🤖 Repository layout updated to latest version
This commit was automatically generated by [a script](https://gitfub.space/Jmaa/repo-manager)
2025-04-13 18:46:58 +02:00
41ffccfca3 🤖 Bumped version to 0.1.4
All checks were successful
Run Python tests (through Pytest) / Test (push) Successful in 25s
Verify Python project can be installed, loaded and have version checked / Test (push) Successful in 22s
Package Python / Package (push) Successful in 25s
This commit was automatically generated by [a script](https://gitfub.space/Jmaa/repo-manager)
2025-04-13 14:12:34 +02:00
8b60454fd0 🤖 Repository layout updated to latest version
This commit was automatically generated by [a script](https://gitfub.space/Jmaa/repo-manager)
2025-04-13 14:12:25 +02:00
7852f00cb9 Update requirements
Some checks failed
Verify Python project can be installed, loaded and have version checked / Test (push) Waiting to run
Run Python tests (through Pytest) / Test (push) Has been cancelled
2025-04-13 14:12:16 +02:00
ff5550db95 Ruff
Some checks failed
Run Python tests (through Pytest) / Test (push) Failing after 24s
Verify Python project can be installed, loaded and have version checked / Test (push) Failing after 21s
2025-04-13 13:55:20 +02:00
fbded540e2 Remove debug prints 2025-04-13 13:54:58 +02:00
561e03ab3c Test vectors 2025-04-13 13:46:52 +02:00
99190e910b Use coincurve for crypto 2025-04-13 13:22:38 +02:00
9033c8df3d Messing around with signing 2025-04-13 12:17:18 +02:00
dae2168d8f Ruff
Some checks failed
Run Python tests (through Pytest) / Test (push) Failing after 23s
Verify Python project can be installed, loaded and have version checked / Test (push) Failing after 22s
2025-04-13 01:19:47 +02:00
5afd7e596f Derive address correctly
Some checks failed
Run Python tests (through Pytest) / Test (push) Failing after 23s
Verify Python project can be installed, loaded and have version checked / Test (push) Has been cancelled
2025-04-13 01:19:18 +02:00
665f070028 Standardize format
Some checks failed
Run Python tests (through Pytest) / Test (push) Failing after 23s
Verify Python project can be installed, loaded and have version checked / Test (push) Failing after 21s
2025-04-12 20:56:23 +02:00
ba7f44e484 Import version 2025-04-12 20:54:53 +02:00
10 changed files with 233 additions and 90 deletions

View File

@ -1,3 +1,10 @@
# Conventions
When contributing code to this project, you MUST follow the requirements
specified here.
## Code Conventions
When contributing code to this project, you MUST follow these principles: When contributing code to this project, you MUST follow these principles:
- Code should be easy to read and understand. - Code should be easy to read and understand.
@ -13,3 +20,13 @@ When contributing code to this project, you MUST follow these principles:
- Documentation should document semantics, not syntax. - Documentation should document semantics, not syntax.
- Prefer importing modules, not individual items from modules. - Prefer importing modules, not individual items from modules.
- Do not use f-strings in logging statements. - Do not use f-strings in logging statements.
- Loop variables and walrus-expression-variables should be deleted when
unneeded to keep scope clean, and to avoid accidental use.
## Testing
When contributing test to this project, you MUST follow these principles:
- Do not use any testing libraries other than `pytest`.
- Mocking is the root of all evil. Writing your own stubs is much more
preferable.

View File

@ -17,7 +17,16 @@ Group ApS.
This project requires [Python](https://www.python.org/) 3.8 or newer. This project requires [Python](https://www.python.org/) 3.8 or newer.
This project does not have any library requirements 😎 All required libraries can be installed easily using:
```bash
pip install -r requirements.txt
```
Full list of requirements:
- [coincurve](https://pypi.org/project/coincurve/)
- [pbcabi](https://gitfub.space/Jmaa/pbcabi)
## Contributing ## Contributing

View File

@ -1,29 +1,32 @@
""" # Partisia Blockchain Client """# Partisia Blockchain Client
Unofficial library for reading contract states from Partisia Blockchain. Unofficial library for reading contract states from Partisia Blockchain.
This library is not officially associated with Partisia Blockchain nor Partisia This library is not officially associated with Partisia Blockchain nor Partisia
Group ApS. Group ApS.
""" """
import base64
import dataclasses import dataclasses
import datetime import datetime
import base64
import email.utils import email.utils
import json import json
import logging import logging
import ecdsa
import hashlib
import pbcabi
from collections.abc import Mapping
import time import time
from collections.abc import Mapping
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
import pbcabi
import requests import requests
from frozendict import frozendict from frozendict import frozendict
from .pbc_types import (Address, Signature, HashSha256, Transaction, from ._version import __version__ # noqa: F401
SignedTransaction, SignedTransactionInnerPart) from .crypto import SenderAuthentication, sign_transaction
from .pbc_types import (
Address,
SignedTransaction,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -40,6 +43,8 @@ URL_NONCE = 'https://{hostname}/{shard}blockchain/account/{address}'
MPC_DECIMALS = 10000 MPC_DECIMALS = 10000
TRANSACTION_VALIDITY_DURATION = 60
def shard_id_for_address(address: str | Address) -> str: def shard_id_for_address(address: str | Address) -> str:
address = str(address) address = str(address)
@ -47,7 +52,7 @@ def shard_id_for_address(address: str | Address) -> str:
if address is None: if address is None:
msg = 'Address must not be None' msg = 'Address must not be None'
raise TypeError(msg) raise TypeError(msg)
if address.endswith('a'): if address.endswith(('a', 'd', 'c')):
return 'shards/Shard0/' return 'shards/Shard0/'
if address.endswith('2'): if address.endswith('2'):
return 'shards/Shard1/' return 'shards/Shard1/'
@ -60,49 +65,6 @@ class Balances:
mpc: Decimal mpc: Decimal
byoc: Mapping[str, Decimal] byoc: Mapping[str, Decimal]
@dataclasses.dataclass(frozen=True)
class SenderAuthentication:
secret_key: str
def sender_address(self) -> Address:
derp = self._signing_key().verifying_key.to_string()
hashed = HashSha256.of_bytes(derp)
print(derp.hex())
print(derp)
print(hashed)
return Address(b'\0' + hashed._bytes[-20:])
def sign_hash(self, _hash: HashSha256) -> Signature:
return Signature(self._signing_key().sign(_hash._bytes))
def _signing_key(self):
return ecdsa.SigningKey.from_string(bytearray.fromhex(self.secret_key), curve=ecdsa.SECP256k1)
TRANSACTION_VALIDITY_DURATION = 60
def sign_transaction(
sender_authentication: SenderAuthentication,
nonce: int,
gas_cost: int,
chain_id: str,
contract_address: Address | str,
transaction_rpc: bytes,
) -> SignedTransaction:
sender = sender_authentication.sender_address
valid_to_time: int = int(time.time() + TRANSACTION_VALIDITY_DURATION) * 1000
inner: SignedTransactionInnerPart = SignedTransactionInnerPart(nonce,
valid_to_time,
gas_cost,
Transaction(Address.from_string(contract_address),
transaction_rpc))
transaction_hash_bytes = inner.rpc_serialize() + chain_id.encode('utf8')
transaction_hash: HashSha256 = HashSha256.of_bytes(transaction_hash_bytes)
signature : Signature = sender_authentication.sign_hash(transaction_hash)
return SignedTransaction(inner, signature, transaction_hash)
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class PbcClient: class PbcClient:
@ -117,29 +79,45 @@ class PbcClient:
if self.sender_authentication is not None: if self.sender_authentication is not None:
assert isinstance(self.sender_authentication, SenderAuthentication) assert isinstance(self.sender_authentication, SenderAuthentication)
def send_transaction(self, contract_address: str, rpc: bytes, gas_cost: int): def send_transaction(self, contract_address: str, rpc: bytes, gas_cost: int):
if self.sender_authentication is None: if self.sender_authentication is None:
msg = "PbcClient.sender_authentication required for send_transaction" msg = 'PbcClient.sender_authentication required for send_transaction'
raise Exception(msg) raise Exception(msg)
signed_transaction = sign_transaction(self.sender_authentication, valid_to_time: int = int(time.time() + TRANSACTION_VALIDITY_DURATION) * 1000
signed_transaction = sign_transaction(
self.sender_authentication,
self.get_sender_authentication_nonce(), self.get_sender_authentication_nonce(),
valid_to_time,
gas_cost, gas_cost,
self.get_chain_id(), self.get_chain_id(),
contract_address, rpc) contract_address,
rpc,
)
assert len(signed_transaction.inner.transaction.rpc_serialize()) == 25
assert len(signed_transaction.signature.rpc_serialize()) == 65
assert len(signed_transaction.inner.rpc_serialize()) == 49
assert len(signed_transaction.rpc_serialize()) == 114
return self.send_signed_transaction(signed_transaction) return self.send_signed_transaction(signed_transaction)
def send_signed_transaction(self, signed_transaction: SignedTransaction): def send_signed_transaction(self, signed_transaction: SignedTransaction):
url = URL_TRANSACTION.format( url = URL_TRANSACTION.format(
hostname=self.hostname, hostname=self.hostname,
shard=shard_id_for_address(signed_transaction.inner.transaction.contract_address), shard=shard_id_for_address(
signed_transaction.inner.transaction.contract_address,
),
) )
transaction_payload: str = base64.b64encode(signed_transaction.rpc_serialize()).decode('utf8') transaction_payload: str = base64.b64encode(
self._get_json(url, data = {'transactionPayload':transaction_payload }, method = 'PUT') signed_transaction.rpc_serialize(),
).decode('utf8')
self._get_json(
url,
data={'transactionPayload': transaction_payload},
method='PUT',
)
def _get_json( def _get_json(
self, self,
@ -163,6 +141,8 @@ class PbcClient:
'date', 'date',
) )
date = email.utils.parsedate_to_datetime(date_text) date = email.utils.parsedate_to_datetime(date_text)
if response.text == '':
return (None, date)
json_data = response.json() json_data = response.json()
if json_data is None: if json_data is None:
msg = 'No result data for ' + url msg = 'No result data for ' + url
@ -226,7 +206,6 @@ class PbcClient:
) )
return self._get_json(url, method='GET')[0]['nonce'] return self._get_json(url, method='GET')[0]['nonce']
def get_contract_state(self, address: str) -> tuple[dict, datetime.datetime]: def get_contract_state(self, address: str) -> tuple[dict, datetime.datetime]:
# TODO: Rename to get_contract_state_json # TODO: Rename to get_contract_state_json
url = URL_CONTRACT_STATE.format( url = URL_CONTRACT_STATE.format(
@ -248,7 +227,11 @@ class PbcClient:
return state_deserialized, server_time return state_deserialized, server_time
def get_typed_contract_avl_tree(self, address: str, avl_tree_id: pbcabi.model.AvlTreeId) -> tuple[dict, datetime.datetime]: def get_typed_contract_avl_tree(
self,
address: str,
avl_tree_id: pbcabi.model.AvlTreeId,
) -> tuple[dict, datetime.datetime]:
file_abi = self.get_contract_abi(address) file_abi = self.get_contract_abi(address)
state, server_time = self.get_contract_state(address) state, server_time = self.get_contract_state(address)
for avl_tree in state['avlTrees']: for avl_tree in state['avlTrees']:
@ -260,8 +243,14 @@ class PbcClient:
key_bytes = base64.b64decode(key_and_value['key']['data']['data']) key_bytes = base64.b64decode(key_and_value['key']['data']['data'])
value_bytes = base64.b64decode(key_and_value['value']['data']) value_bytes = base64.b64decode(key_and_value['value']['data'])
key = file_abi.contract.read_state(key_bytes, avl_tree_id.type_spec.type_key) key = file_abi.contract.read_state(
value = file_abi.contract.read_state(value_bytes, avl_tree_id.type_spec.type_value) key_bytes,
avl_tree_id.type_spec.type_key,
)
value = file_abi.contract.read_state(
value_bytes,
avl_tree_id.type_spec.type_value,
)
data[key] = value data[key] = value
@ -269,7 +258,6 @@ class PbcClient:
return data, server_time return data, server_time
def get_contract_abi(self, address: str) -> pbcabi.model.FileAbi: def get_contract_abi(self, address: str) -> pbcabi.model.FileAbi:
url = URL_CONTRACT_STATE.format( url = URL_CONTRACT_STATE.format(
hostname=self.hostname, hostname=self.hostname,

View File

@ -1 +1 @@
__version__ = '0.1.3' __version__ = '0.1.5'

84
pbc_client/crypto.py Normal file
View File

@ -0,0 +1,84 @@
import dataclasses
import coincurve
from .pbc_types import (
Address,
HashSha256,
Signature,
SignedTransaction,
SignedTransactionInnerPart,
Transaction,
)
def find_recovery_id(
der_sig: bytes,
message_hash: HashSha256,
expected_public_key: coincurve.PublicKey,
) -> int:
r, s = coincurve.der.parse_signature(der_sig)
for recovery_id in range(4):
recovered_public_key = coincurve.PublicKey.from_signature_and_message(
signature=r + s + bytes([recovery_id]),
message=message_hash._bytes,
hasher=None,
)
if recovered_public_key.format() == expected_public_key.format():
return recovery_id
return None
@dataclasses.dataclass(frozen=True)
class SenderAuthentication:
_private_key_hex: str
def sender_address(self) -> Address:
verifying_key_repr = self._public_key().format(False)
assert len(verifying_key_repr) == 65
hashed = HashSha256.of_bytes(verifying_key_repr)
return Address(b'\0' + hashed._bytes[-20:])
def sign_hash(self, hash_to_sign: HashSha256) -> Signature:
private_key = self._private_key()
signature_wrong_location = private_key.sign_recoverable(
hash_to_sign._bytes,
hasher=lambda x: x,
)
signature = signature_wrong_location[64:] + signature_wrong_location[:64]
return Signature(signature)
def _public_key(self) -> coincurve.PublicKey:
return self._private_key().public_key
def _private_key(self) -> coincurve.PrivateKey:
return coincurve.PrivateKey.from_hex(
self._private_key_hex,
)
def sign_transaction(
sender_authentication: SenderAuthentication,
nonce: int,
valid_to_time: int,
gas_cost: int,
chain_id: str,
contract_address: Address | str,
transaction_rpc: bytes,
) -> SignedTransaction:
sender = sender_authentication.sender_address
inner: SignedTransactionInnerPart = SignedTransactionInnerPart(
nonce,
valid_to_time,
gas_cost,
Transaction(Address.from_string(contract_address), transaction_rpc),
)
signature: Signature = sender_authentication.sign_hash(inner.hash(chain_id))
return SignedTransaction(inner, signature)

View File

@ -1,22 +1,11 @@
import dataclasses import dataclasses
import datetime
import base64
import email.utils
import json
import logging
import hashlib import hashlib
import pbcabi
from collections.abc import Mapping
import time
from decimal import Decimal
from typing import Any
def size_prefixed(_bytes: bytes) -> bytes: def size_prefixed(_bytes: bytes) -> bytes:
return len(_bytes).to_bytes(4, 'big') + _bytes return len(_bytes).to_bytes(4, 'big') + _bytes
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Address: class Address:
_bytes: bytes _bytes: bytes
@ -31,7 +20,7 @@ class Address:
return self._bytes.hex() return self._bytes.hex()
@staticmethod @staticmethod
def from_string(s: str) -> 'Address': def from_string(s: 'Address | str') -> 'Address':
if isinstance(s, Address): if isinstance(s, Address):
return s return s
return Address(bytes.fromhex(s)) return Address(bytes.fromhex(s))
@ -42,7 +31,8 @@ class Signature:
_bytes: bytes _bytes: bytes
def __post_init__(self): def __post_init__(self):
assert len(self._bytes) == 64, len(self._bytes) assert len(self._bytes) == 65, len(self._bytes)
assert self._bytes[0] < 4, 'First byte is the recovery id. Must be less than 4'
def rpc_serialize(self) -> bytes: def rpc_serialize(self) -> bytes:
return self._bytes return self._bytes
@ -50,6 +40,7 @@ class Signature:
def __str__(self) -> str: def __str__(self) -> str:
return self._bytes.hex() return self._bytes.hex()
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class HashSha256: class HashSha256:
_bytes: bytes _bytes: bytes
@ -67,6 +58,7 @@ class HashSha256:
def __str__(self) -> str: def __str__(self) -> str:
return self._bytes.hex() return self._bytes.hex()
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Transaction: class Transaction:
contract_address: Address contract_address: Address
@ -76,7 +68,10 @@ class Transaction:
assert isinstance(self.contract_address, Address), self.contract_address assert isinstance(self.contract_address, Address), self.contract_address
def rpc_serialize(self) -> bytes: def rpc_serialize(self) -> bytes:
return self.contract_address.rpc_serialize() + size_prefixed(self.transaction_rpc) return self.contract_address.rpc_serialize() + size_prefixed(
self.transaction_rpc,
)
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class SignedTransactionInnerPart: class SignedTransactionInnerPart:
@ -86,13 +81,26 @@ class SignedTransactionInnerPart:
transaction: Transaction transaction: Transaction
def rpc_serialize(self) -> bytes: def rpc_serialize(self) -> bytes:
return self.nonce.to_bytes(4, 'big') + self.valid_to_time.to_bytes(8, 'big') + self.gas_cost.to_bytes(8, 'big') + self.transaction.rpc_serialize() return (
self.nonce.to_bytes(8, 'big')
+ self.valid_to_time.to_bytes(8, 'big')
+ self.gas_cost.to_bytes(8, 'big')
+ self.transaction.rpc_serialize()
)
def hash(self, chain_id: str) -> HashSha256:
return HashSha256.of_bytes(
self.rpc_serialize() + size_prefixed(chain_id.encode('utf8')),
)
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class SignedTransaction: class SignedTransaction:
inner: SignedTransactionInnerPart inner: SignedTransactionInnerPart
signature: Signature signature: Signature
hash: HashSha256
def hash(self, chain_id: str) -> HashSha256:
return self.inner.hash(chain_id)
def rpc_serialize(self) -> bytes: def rpc_serialize(self) -> bytes:
return self.inner.rpc_serialize() + self.signature.rpc_serialize() + self.hash.rpc_serialize() return self.signature.rpc_serialize() + self.inner.rpc_serialize()

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
coincurve
pbcabi @ git+https://gitfub.space/Jmaa/pbcabi

View File

@ -11,7 +11,7 @@ from setuptools import setup
PACKAGE_NAME = 'pbc_client' PACKAGE_NAME = 'pbc_client'
PACKAGE_DESCRIPTION = """ PACKAGE_DESCRIPTION = """
# Partisia Blockchain Client # Partisia Blockchain Client
Unofficial library for reading contract states from Partisia Blockchain. Unofficial library for reading contract states from Partisia Blockchain.
@ -35,7 +35,10 @@ with open(PACKAGE_NAME + '/_version.py') as f:
version = parse_version_file(f.read()) version = parse_version_file(f.read())
REQUIREMENTS_MAIN = [] REQUIREMENTS_MAIN = [
'coincurve',
'pbcabi @ git+https://gitfub.space/Jmaa/pbcabi',
]
REQUIREMENTS_TEST = [] REQUIREMENTS_TEST = []

30
test/test_crypto.py Normal file
View File

@ -0,0 +1,30 @@
from pbc_client.crypto import SenderAuthentication, sign_transaction
def test_sign():
sender_authentication = SenderAuthentication('01')
chain_id = 'SpecificChainIDLocatable'
contract_address = '000000000000000000000000000000000000000001'
signed = sign_transaction(
sender_authentication=sender_authentication,
nonce=2,
valid_to_time=3,
gas_cost=2,
chain_id=chain_id,
contract_address=contract_address,
transaction_rpc=bytes([0 for i in range(99)]),
)
print(signed)
assert (
str(signed.inner.rpc_serialize().hex())
== '00000000000000020000000000000003000000000000000200000000000000000000000000000000000000000100000063000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'
)
assert (
str(signed.hash(chain_id))
== '1be2895a85862e8dd3cc75b290cc28a22e960ae02dd5c07a73b93716f9adbee8'
)
assert (
str(signed.signature)
== '01f0f7d8f44919466eedbebc76bcb369bbb4c6f4f076e25c0ffe8ec9285890e53b4e39098540d088878a019c345ad73963543ce813fb9ccf4b84b0c25770452bd1'
)

2
test/test_init.py Normal file
View File

@ -0,0 +1,2 @@
def test_init():
import pbc_client # noqa: F401