Sending transactions is complicated stuff
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

This commit is contained in:
Jon Michael Aanes 2025-04-11 00:16:40 +02:00
parent a4fc294fd9
commit 5021b6c540
2 changed files with 173 additions and 6 deletions

View File

@ -11,14 +11,20 @@ import base64
import email.utils import email.utils
import json import json
import logging import logging
import ecdsa
import hashlib
import pbcabi import pbcabi
from collections.abc import Mapping from collections.abc import Mapping
import time
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
import requests import requests
from frozendict import frozendict from frozendict import frozendict
from .pbc_types import (Address, Signature, HashSha256, Transaction,
SignedTransaction, SignedTransactionInnerPart)
logger = logging.getLogger(__name__) 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 = 'https://{hostname}/{shard}blockchain/accountPlugin/local'
URL_ACCOUNT_PLUGIN_GLOBAL = 'https://{hostname}/{shard}blockchain/accountPlugin/global' 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_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 MPC_DECIMALS = 10000
@ -51,13 +59,75 @@ 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:
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) @dataclasses.dataclass(frozen=True)
class PbcClient: class PbcClient:
session: requests.Session session: requests.Session
sender_authentication: SenderAuthentication | None = None
hostname: str = HOSTNAME_MAINNET 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, self,
url: str, url: str,
data: Mapping[str, Any] = frozendict(), data: Mapping[str, Any] = frozendict(),
@ -93,7 +163,7 @@ class PbcClient:
shard='', shard='',
) )
json_data, date = self.get_json(url, data=data) json_data, date = self._get_json(url, data=data)
return json_data['coins']['coins'] return json_data['coins']['coins']
def get_account_balances(self, address: str) -> Balances: def get_account_balances(self, address: str) -> Balances:
@ -110,7 +180,7 @@ class PbcClient:
{'type': 'avl', 'keyType': 'BLOCKCHAIN_ADDRESS', 'key': address}, {'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] = {} byoc: dict[str, Decimal] = {}
mpc = Decimal(account_data['mpcTokens']) / MPC_DECIMALS mpc = Decimal(account_data['mpcTokens']) / MPC_DECIMALS
@ -125,6 +195,24 @@ class PbcClient:
return Balances(date, mpc, byoc) 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]: 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(
@ -133,7 +221,7 @@ class PbcClient:
address=address, address=address,
) )
data: dict = {'path': []} 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]: def get_typed_contract_state(self, address: str) -> tuple[dict, datetime.datetime]:
""" """
@ -174,6 +262,6 @@ class PbcClient:
shard=shard_id_for_address(address), shard=shard_id_for_address(address),
address=address, address=address,
) )
meta, _ = self.get_json(url, method='GET') meta, _ = self._get_json(url, method='GET')
abi_bytes = base64.b64decode(meta['abi']) abi_bytes = base64.b64decode(meta['abi'])
return pbcabi.model.FileAbi.read_from(abi_bytes) return pbcabi.model.FileAbi.read_from(abi_bytes)

79
pbc_client/pbc_types.py Normal file
View File

@ -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()