From 5021b6c540fa5144bbd28410f7ab555d06f4fd80 Mon Sep 17 00:00:00 2001 From: Jon Michael Aanes Date: Fri, 11 Apr 2025 00:16:40 +0200 Subject: [PATCH] Sending transactions is complicated stuff --- pbc_client/__init__.py | 100 +++++++++++++++++++++++++++++++++++++--- pbc_client/pbc_types.py | 79 +++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 pbc_client/pbc_types.py diff --git a/pbc_client/__init__.py b/pbc_client/__init__.py index 5a96ddd..cc0e105 100644 --- a/pbc_client/__init__.py +++ b/pbc_client/__init__.py @@ -11,14 +11,20 @@ import base64 import email.utils import json import logging +import ecdsa +import hashlib import pbcabi from collections.abc import Mapping +import time from decimal import Decimal from typing import Any import requests from frozendict import frozendict +from .pbc_types import (Address, Signature, HashSha256, Transaction, +SignedTransaction, SignedTransactionInnerPart) + logger = logging.getLogger(__name__) @@ -27,8 +33,10 @@ HOSTNAME_TESTNET = 'node1.testnet.partisiablockchain.com' URL_ACCOUNT_PLUGIN = 'https://{hostname}/{shard}blockchain/accountPlugin/local' URL_ACCOUNT_PLUGIN_GLOBAL = 'https://{hostname}/{shard}blockchain/accountPlugin/global' +URL_TRANSACTION = 'https://{hostname}/{shard}blockchain/transaction' URL_CONTRACT_STATE = 'https://{hostname}/{shard}blockchain/contracts/{address}?requireContractState=false' - +URL_CHAIN_ID = 'https://{hostname}/blockchain/chainId' +URL_NONCE = 'https://{hostname}/{shard}blockchain/account/{address}' MPC_DECIMALS = 10000 @@ -51,13 +59,75 @@ class Balances: mpc: Decimal byoc: Mapping[str, Decimal] +@dataclasses.dataclass(frozen=True) +class SenderAuthentication: + secret_key: str + + def sender_address(self) -> Address: + return Address(self._signing_key().verifying_key.to_string()) + + 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: 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(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) class PbcClient: session: requests.Session + sender_authentication: SenderAuthentication | None = None hostname: str = HOSTNAME_MAINNET - def get_json( + def send_transaction(self, contract_address: str, rpc: bytes, gas_cost: int): + if self.sender_authentication is None: + msg = "PbcClient.sender_authentication required for send_transaction" + raise Exception(msg) + + signed_transaction = sign_transaction(self.sender_authentication, + self.get_sender_authentication_nonce(), + gas_cost, + self.get_chain_id(), + contract_address, rpc) + + return self.send_signed_transaction(signed_transaction) + + def send_signed_transaction(self, signed_transaction: SignedTransaction): + url = URL_TRANSACTION.format( + hostname=self.hostname, + shard=shard_id_for_address(signed_transaction.inner.transaction.contract_address), + ) + + transaction_payload: str = base64.b64encode(signed_transaction.rpc_serialize()).decode('utf8') + self._get_json(url, data = {'transactionPayload':transaction_payload }, method = 'PUT') + + + def _get_json( self, url: str, data: Mapping[str, Any] = frozendict(), @@ -93,7 +163,7 @@ class PbcClient: shard='', ) - json_data, date = self.get_json(url, data=data) + json_data, date = self._get_json(url, data=data) return json_data['coins']['coins'] def get_account_balances(self, address: str) -> Balances: @@ -110,7 +180,7 @@ class PbcClient: {'type': 'avl', 'keyType': 'BLOCKCHAIN_ADDRESS', 'key': address}, ], } - account_data, date = self.get_json(url, data=data) + account_data, date = self._get_json(url, data=data) byoc: dict[str, Decimal] = {} mpc = Decimal(account_data['mpcTokens']) / MPC_DECIMALS @@ -125,6 +195,24 @@ class PbcClient: return Balances(date, mpc, byoc) + def get_chain_id(self) -> str: + url = URL_NONCE.format( + hostname=self.hostname, + ) + return self._get_json(url, method='GET')[0]['chainId'] + + def get_sender_authentication_nonce(self) -> int: + return self.get_nonce_for_account(self.sender_authentication.sender_address) + + def get_nonce_for_account(self, address: str) -> int: + url = URL_NONCE.format( + hostname=self.hostname, + shard=shard_id_for_address(address), + address=address, + ) + return self._get_json(url, method='GET')[0]['nonce'] + + def get_contract_state(self, address: str) -> tuple[dict, datetime.datetime]: # TODO: Rename to get_contract_state_json url = URL_CONTRACT_STATE.format( @@ -133,7 +221,7 @@ class PbcClient: address=address, ) data: dict = {'path': []} - return self.get_json(url, data=data) + return self._get_json(url, data=data) def get_typed_contract_state(self, address: str) -> tuple[dict, datetime.datetime]: """ @@ -174,6 +262,6 @@ class PbcClient: shard=shard_id_for_address(address), address=address, ) - meta, _ = self.get_json(url, method='GET') + meta, _ = self._get_json(url, method='GET') abi_bytes = base64.b64decode(meta['abi']) return pbcabi.model.FileAbi.read_from(abi_bytes) diff --git a/pbc_client/pbc_types.py b/pbc_client/pbc_types.py new file mode 100644 index 0000000..ec9ed73 --- /dev/null +++ b/pbc_client/pbc_types.py @@ -0,0 +1,79 @@ +import dataclasses +import datetime +import base64 +import email.utils +import json +import logging +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: + return len(_bytes).to_bytes(4, 'big') + _bytes + + + +@dataclasses.dataclass(frozen=True) +class Address: + _bytes: bytes + + def __post_init__(self): + assert len(self._bytes) == 21 + + def rpc_serialize(self) -> bytes: + return self._bytes + +@dataclasses.dataclass(frozen=True) +class Signature: + _bytes: bytes + + def __post_init__(self): + assert len(self._bytes) == 32 + + def rpc_serialize(self) -> bytes: + return self._bytes + +@dataclasses.dataclass(frozen=True) +class HashSha256: + _bytes: bytes + + def __post_init__(self): + assert len(self._bytes) == 32 + + @staticmethod + def of_bytes(b: bytes) -> 'HashSha256': + return HashSha256(hashlib.sha256(b).digest()) + + def rpc_serialize(self) -> bytes: + return self._bytes + +@dataclasses.dataclass(frozen=True) +class Transaction: + contract_address: Address + transaction_rpc: bytes + + def rpc_serialize(self) -> bytes: + return self.contract_address.rpc_serialize() + size_prefixed(self.transaction_rpc) + +@dataclasses.dataclass(frozen=True) +class SignedTransactionInnerPart: + nonce: int + valid_to_time: int + gas_cost: int + transaction: Transaction + + def rpc_serialize(self) -> bytes: + return self.nonce.to_bytes(4, 'big') + self.valid_to_time.to_bytes(4, 'big') + self.gas_cost.to_bytes(4, 'big') + self.transaction.rpc_serialize() + +@dataclasses.dataclass(frozen=True) +class SignedTransaction: + inner: SignedTransactionInnerPart + signature: Signature + hash: HashSha256 + + def rpc_serialize(self) -> bytes: + return self.inner.rpc_serialize() + self.signature.rpc_serialize() + self.hash.rpc_serialize()