Source code for blockchainkit.structures.systems.transaction
"""Immutable signed account transfers and deterministic teaching encodings."""
from dataclasses import dataclass, replace
from blockchainkit._validation import integer
from blockchainkit.constants import DEFAULT_CHAIN_ID, UINT64_LIMIT
from blockchainkit.crypto import (
SchnorrSignature,
encode_point,
public_key,
sha256,
sign,
verify,
)
from blockchainkit.structures.utils.accounts import is_account_id
from blockchainkit.structures.utils.encoding import canonical_json
[docs]
def address(public: tuple[int, int]) -> str:
"""Return SHA-256(uncompressed public key) as a 64-character account ID."""
return sha256(encode_point(public)).hex()
[docs]
@dataclass(frozen=True)
class Transaction:
"""An integer-valued transfer, signed over network ID and account nonce.
Parameters
----------
sender : tuple of int
secp256k1 public point.
recipient : str
Lowercase 64-character hexadecimal account identifier.
amount : int
Positive integer units (no floating-point currency amounts).
nonce : int
Sender sequence number, starting at zero.
chain_id : str
Domain that prevents replay onto a different teaching network.
signature : SchnorrSignature, optional
Signature over the canonical unsigned payload.
"""
sender: tuple[int, int]
recipient: str
amount: int
nonce: int
chain_id: str = DEFAULT_CHAIN_ID
signature: SchnorrSignature | None = None
def __post_init__(self) -> None:
encode_point(self.sender)
if not is_account_id(self.recipient):
raise ValueError("recipient must be a lowercase SHA-256 account identifier")
integer(self.amount, "amount", 1)
integer(self.nonce, "nonce")
if self.amount >= UINT64_LIMIT or self.nonce >= UINT64_LIMIT:
raise ValueError("amount and nonce must fit unsigned 64-bit integers")
if not isinstance(self.chain_id, str) or not 1 <= len(self.chain_id) <= 128:
raise ValueError("chain_id must contain 1 to 128 characters")
if self.signature is not None and not isinstance(self.signature, SchnorrSignature):
raise TypeError("signature must be a SchnorrSignature or None")
@property
def sender_address(self) -> str:
"""Return the account identifier derived from the sender's public key."""
return address(self.sender)
[docs]
def payload(self) -> bytes:
"""Return the exact bytes signed, including domain and format version."""
return canonical_json(
{
"version": 1,
"chain_id": self.chain_id,
"sender": encode_point(self.sender).hex(),
"recipient": self.recipient,
"amount": self.amount,
"nonce": self.nonce,
}
)
[docs]
def to_bytes(self) -> bytes:
"""Serialize the unsigned payload and signature without ambiguity."""
signature = None
if self.signature is not None:
signature = [list(self.signature.commitment), self.signature.response]
return canonical_json({"payload": self.payload().decode(), "signature": signature})
@property
def txid(self) -> bytes:
"""Return a digest of the signed transaction encoding.
Because it covers the signature, re-signing the same payment changes
it: the malleability that segregated witness removed from Bitcoin's
transaction ids. Compare :attr:`unsigned_id`.
"""
return sha256(self.to_bytes())
@property
def unsigned_id(self) -> bytes:
"""Return a digest of the unsigned payload only, as SegWit's txid does.
Any valid signature over the same payment gives the same value, so a
later transaction can refer to this one before it is confirmed.
"""
return sha256(self.payload())
[docs]
def is_valid(self) -> bool:
"""Check the signature; account balance and nonce are checked by Ledger."""
return self.signature is not None and verify(self.payload(), self.signature, self.sender)
[docs]
def signed(self, private: int, *, signing_nonce: int | None = None) -> "Transaction":
"""Return a signed copy, checking that the key matches this sender."""
if public_key(private) != self.sender:
raise ValueError("private key does not match sender")
return replace(self, signature=sign(self.payload(), private, nonce=signing_nonce))