Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
43 changes: 25 additions & 18 deletions minichain/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,29 @@


class Transaction:
# 1. List the fields that, if changed, should break the cache
_TX_FIELDS = {"sender", "receiver", "amount", "nonce", "data", "timestamp", "signature"}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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
# We set these first
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 = timestamp if timestamp else round(time.time() * 1000)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
self.signature = signature

# Initialize cache last
self._cached_tx_id = None
Comment thread
sanaica marked this conversation as resolved.

# 2. The "Watcher" function
def __setattr__(self, name, value):
# Perform the actual assignment
super().__setattr__(name, value)
# If a core field was changed, and the cache exists, kill the cache
if name in self._TX_FIELDS and hasattr(self, "_cached_tx_id"):
super().__setattr__("_cached_tx_id", None)

def to_dict(self):
return {
Expand Down Expand Up @@ -60,14 +70,15 @@ def hash_payload(self):

@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)
# Setting this now automatically clears the cache because of __setattr__!
self.signature = signed.signature.hex()

def verify(self):
Expand All @@ -80,8 +91,4 @@ def verify(self):
return True

except (BadSignatureError, CryptoError, ValueError, TypeError):
# Covers:
# - Invalid signature
# - Malformed public key hex
# - Invalid hex in signature
return False
return False
44 changes: 44 additions & 0 deletions tests/test_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from minichain.transaction import Transaction
from nacl.signing import SigningKey
from nacl.encoding import HexEncoder

def test_tx_caching():
"""Verifies standard lifecycle caching: None -> Filled -> Cleared by Sign."""
sk = SigningKey.generate()
sender_hex = sk.verify_key.encode(encoder=HexEncoder).decode()

tx = Transaction(sender=sender_hex, receiver="addr", amount=100, nonce=1)

# 1. Initial State
assert tx._cached_tx_id is None

# 2. First access (triggers calculation)
first_id = tx.tx_id
assert tx._cached_tx_id == first_id

# 3. Signing (must clear cache automatically via __setattr__)
tx.sign(sk)
assert tx._cached_tx_id is None

# 4. Re-calculate after sign
signed_id = tx.tx_id
assert signed_id != first_id
assert tx._cached_tx_id == signed_id

def test_tx_mutation_clears_cache():
"""Verifies that direct field updates also clear the cache (Bulletproof check)."""
tx = Transaction(sender="alice", receiver="bob", amount=100, nonce=1)

# 1. Fill the cache
original_id = tx.tx_id
assert tx._cached_tx_id is not None

# 2. Mutate a field directly (e.g., changing the amount)
tx.amount = 500

# 3. ASSERT: Cache must be None immediately after mutation
assert tx._cached_tx_id is None

# 4. ASSERT: New ID must be different from the old one
new_id = tx.tx_id
assert new_id != original_id
Loading