Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 39 additions & 51 deletions minichain/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,82 +6,70 @@


class Transaction:
_TX_FIELDS = frozenset({"sender", "receiver", "amount", "nonce", "data", "timestamp", "signature"})

def __setattr__(self, name, value) -> None:
if name in self._TX_FIELDS and getattr(self, "_sealed", False):
raise AttributeError(f"Transaction is sealed; cannot modify '{name}'")
super().__setattr__(name, value)
if name in self._TX_FIELDS and hasattr(self, "_cached_tx_id"):
super().__setattr__("_cached_tx_id", None)

@staticmethod
def _normalize_ts(ts) -> int:
ts = int(ts)
return ts * 1000 if ts < 1e12 else ts

def __init__(self, sender, receiver, amount, nonce, data=None, signature=None, timestamp=None):
self.sender = sender # Public key (Hex str)
self.receiver = receiver # Public key (Hex str) or None for Deploy
self.sender = sender
self.receiver = receiver
self.amount = amount
self.nonce = nonce
self.data = data # Preserve None (do NOT normalize to "")
if timestamp is None:
self.timestamp = round(time.time() * 1000) # New tx: seconds → ms
elif timestamp > 1e12:
self.timestamp = int(timestamp) # Already in ms (from network)
else:
self.timestamp = round(timestamp * 1000) # Seconds → ms
self.signature = signature # Hex str
self.data = data
self.timestamp = self._normalize_ts(timestamp) if timestamp is not None else round(time.time() * 1000)
self.signature = signature
self._cached_tx_id = None
Comment thread
sanaica marked this conversation as resolved.
self._sealed = False

def to_dict(self):
return {
"sender": self.sender,
"receiver": self.receiver,
"amount": self.amount,
"nonce": self.nonce,
"data": self.data,
"timestamp": self.timestamp,
"signature": self.signature,
}
return {"sender": self.sender, "receiver": self.receiver, "amount": self.amount,
"nonce": self.nonce, "data": self.data, "timestamp": self.timestamp,
"signature": self.signature}

def to_signing_dict(self):
return {
"sender": self.sender,
"receiver": self.receiver,
"amount": self.amount,
"nonce": self.nonce,
"data": self.data,
"timestamp": self.timestamp,
}
return {"sender": self.sender, "receiver": self.receiver, "amount": self.amount,
"nonce": self.nonce, "data": self.data, "timestamp": self.timestamp}

@classmethod
def from_dict(cls, payload: dict):
return cls(
sender=payload["sender"],
receiver=payload.get("receiver"),
amount=payload["amount"],
nonce=payload["nonce"],
data=payload.get("data"),
signature=payload.get("signature"),
timestamp=payload.get("timestamp"),
)
return cls(sender=payload["sender"], receiver=payload.get("receiver"),
amount=payload["amount"], nonce=payload["nonce"],
data=payload.get("data"), signature=payload.get("signature"),
timestamp=payload.get("timestamp"))

@property
def hash_payload(self):
"""Returns the bytes to be signed."""
return canonical_json_bytes(self.to_signing_dict())

@property
def tx_id(self):
"""Deterministic identifier for the signed transaction."""
return canonical_json_hash(self.to_dict())
if self._cached_tx_id is None:
self._cached_tx_id = canonical_json_hash(self.to_dict())
return self._cached_tx_id

def sign(self, signing_key: SigningKey):
# Validate that the signing key matches the sender
if signing_key.verify_key.encode(encoder=HexEncoder).decode() != self.sender:
raise ValueError("Signing key does not match sender")
signed = signing_key.sign(self.hash_payload)
self.signature = signed.signature.hex()
self.signature = signing_key.sign(self.hash_payload).signature.hex()
self._sealed = True

def verify(self):
if not self.signature:
return False

try:
verify_key = VerifyKey(self.sender, encoder=HexEncoder)
verify_key.verify(self.hash_payload, bytes.fromhex(self.signature))
return True

VerifyKey(self.sender, encoder=HexEncoder).verify(
self.hash_payload, bytes.fromhex(self.signature))
except (BadSignatureError, CryptoError, ValueError, TypeError):
# Covers:
# - Invalid signature
# - Malformed public key hex
# - Invalid hex in signature
return False
else:
return True
48 changes: 48 additions & 0 deletions tests/test_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import pytest
from minichain.transaction import Transaction
from nacl.signing import SigningKey
from nacl.encoding import HexEncoder


def test_tx_caching():
sk = SigningKey.generate()
sender_hex = sk.verify_key.encode(encoder=HexEncoder).decode()
tx = Transaction(sender=sender_hex, receiver="addr", amount=100, nonce=1)

assert tx._cached_tx_id is None
first_id = tx.tx_id
assert tx._cached_tx_id == first_id
assert tx.tx_id == first_id # second access, same result

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
tx.sign(sk)
assert tx._cached_tx_id is None

signed_id = tx.tx_id
assert signed_id != first_id
assert tx._cached_tx_id == signed_id


def test_tx_mutation_clears_cache():
tx = Transaction(sender="alice", receiver="bob", amount=100, nonce=1)
original_id = tx.tx_id
assert tx._cached_tx_id is not None

tx.amount = 500
assert tx._cached_tx_id is None
assert tx.tx_id != original_id

def test_signed_tx_is_sealed():
# 1. Generate a real key
sk = SigningKey.generate()
# 2. Get the actual hex address for that key
sender_hex = sk.verify_key.encode(encoder=HexEncoder).decode()

# 3. Use that real address as the sender
tx = Transaction(sender=sender_hex, receiver="bob", amount=100, nonce=1)

# 4. Now the signature will be accepted
tx.sign(sk)

# 5. Assert that it is indeed sealed
with pytest.raises(AttributeError, match="Transaction is sealed"):
tx.amount = 500
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading