diff --git a/blockchain.py b/blockchain.py index 3d9ad29..a044dda 100644 --- a/blockchain.py +++ b/blockchain.py @@ -50,6 +50,11 @@ class Transaction: self.amount.to_bytes(8, "big") + \ self.transaction_fee.to_bytes(8, "big") + \ self.signature + def sorting_id(self): + return (-self.transaction_fee, self.sender, self.id) + def __eq__(self, other): + return (self.id, self.sender, self.receiver, self.amount, self.transaction_fee) == \ + (other.id, other.sender, other.receiver, other.amount, other.transaction_fee) @dataclass class Block: @@ -178,6 +183,82 @@ class Block: self.difficulty_sum.to_bytes(32, "big") + \ self.nonce.to_bytes(8, "big") +class OpenTransactions: + def __init__(self, blockchain): + self.__blockchain = blockchain + self.__open_transactions = [] + self.__lock = Lock() + self.__recalculate_hashes() + def add(self, transaction): + assert transaction.is_valid() + with self.__lock: + # pre-check 1: Check for duplicates + for existing_transaction in self.__open_transactions: + if transaction == existing_transaction: + return + # pre-check 2: Check if there is space + if not self.__has_space(transaction): + return + # Add the transaction + self.__open_transactions.append(transaction) + self.__cleanup() + def update(self): + with self.__lock: + self.__cleanup() + def get_hash(self, i): + assert i < 1024 + with self.__lock: + return self.__hashes[i] + def get_transaction(self, i): + with self.__lock: + if i >= len(self.__open_transactions): + return None + return self.__open_transactions[i] + def __has_space(self, transaction): + if len(self.__open_transactions) < 1000: + return True + return transaction.sorting_id() < self.__open_transactions[-1].sorting_id() + def __cleanup(self): + # sort everything + self.__open_transactions.sort(key = Transaction.sorting_id) + # drop out invalid ones + # - reused ids + # - paying more money than available + latest_block = self.__blockchain.get_latest_block() + if latest_block is None: + self.__open_transactions = [] + self.__recalculate_hashes() + return + used_transaction_ids = latest_block.used_transaction_ids.copy() + balances = latest_block.balances.copy() + def is_valid(transaction): + sender_tuple = (transaction.sender, transation.id) + if sender_tuple in used_transaction_ids: + return False + balance = balances.get(transaction.sender) or 0 + if transaction.amount + transaction.transaction_fee > balance: + return False + used_transaction_ids.add(sender_tuple) + balances[transaction.sender] = balance - transaction.amount - transaction.transaction_fee + return True + self.__open_transactions = [transaction for transaction in self.__open_transactions if is_valid(transaction)] + # limit to 1024 + self.__open_transactions = self.__open_transactions[0:1024] + self.__recalculate_hashes() + def __recalculate_hashes(self): + self.__hashes = [] + current_hash = 32 * b"\0" + for i in range(1024): + if i >= len(self.__open_transactions): + transaction_data = 44 * b"\0" + else: + transaction = self.__open_transactions[i] + transaction_data = transaction.transaction_fee.to_bytes(8, "big") + \ + transaction.sender_pubkey + \ + transaction.id.to_bytes(4, "big") + current_hash = hashlib.sha256(current_hash + transaction_data).digest() + self.__hashes.append(current_hash) + class Blockchain: def __init__(self): # maps block hashes to block instances @@ -185,6 +266,7 @@ class Blockchain: self.__latest_block_hash = None self.__lock = Lock() self.__load_blocks_from_disk() + self.open_transactions = OpenTransactions(self) def __load_blocks_from_disk(self): last_valid = None try: @@ -222,6 +304,7 @@ class Blockchain: self.__latest_block_hash = block_hash if persist: self.__persist_block_update(latest_block, new_block) + self.open_transactions.update() return True def __persist_block_update(self, old_block, new_block): if old_block is not None: diff --git a/docs/protocol-v0.md b/docs/protocol-v0.md index f43b34c..683409a 100644 --- a/docs/protocol-v0.md +++ b/docs/protocol-v0.md @@ -178,3 +178,24 @@ The node tells the miner "timestamp", "previous hash", "difficulty sum" and "tra The miner fills "nonce", "message" and "miner pubkey" on its own. When a miner finds a block, it sends a "block transfer" message to the node. + +### Payment request (Client -> Node) + +| content | size (bytes) | +|---|---| +| protocol version = 0 (BE) | 2 | +| capable version = 0 (BE) | 2 | +| type = 9 (BE) | 1 | +| transaction | 148 | + +The node should answer to a "Payment request" with a "Payment request received" if the contained transaction is formally correct (see "validity / transaction" in the blockchain specification). + +A response is always sent back in this case, even if the transaction cannot be applied to the blockchain right now. + +### Payment request received (Node -> Client) + +| content | size (bytes) | +|---|---| +| protocol version = 0 (BE) | 2 | +| capable version = 0 (BE) | 2 | +| type = 10 (BE) | 1 | diff --git a/list-open-transactions.py b/list-open-transactions.py new file mode 100755 index 0000000..27ddab5 --- /dev/null +++ b/list-open-transactions.py @@ -0,0 +1,44 @@ +#! /usr/bin/env python3 + +import base64, socket, sys + +def get_transaction(s, i): + request = b"\0\0\0\0\x05" + i.to_bytes(2, "big") + for _ in range(10): + s.sendto(request, ("::1", 62039)) + try: + while True: + msg, sender = s.recvfrom(4096) + if sender[0:2] == ("::1", 62039) and len(msg) == 155 and msg[0:5] == b"\0\0\0\0\x06" and msg[5:7] == i.to_bytes(2, "big"): + return msg[7:155] + except TimeoutError: + pass + return None + +def format_addr(raw_addr): + return base64.b64encode(raw_addr).decode() + +def format_amount(raw_amount): + int_amount = int.from_bytes(raw_amount, "big") + coins = int_amount // 100 + cents = int_amount % 100 + return f"{coins},{cents:02} cc" + +def main(): + s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + s.settimeout(1) + for i in range(1024): + transaction = get_transaction(s, i) + if transaction is None: + print("- no response from local node -", file=sys.stderr) + exit(1) + if transaction == 148 * b"\0": + return + sender = format_addr(transaction[4:36]) + receiver = format_addr(transaction[36:68]) + amount = format_amount(transaction[68:76]) + fee = format_amount(transaction[76:84]) + print(f"{sender} {receiver} {amount:>13} {fee:>10}") + +if __name__ == '__main__': + main() diff --git a/node.py b/node.py index 1569dd9..c98130a 100755 --- a/node.py +++ b/node.py @@ -82,19 +82,12 @@ def wait_until(target_time): if duration > 0: time.sleep(duration) -def empty_transaction_list_hash(): - current_hash = 32 * b"\0" - for _ in range(1024): - entry = current_hash + 44 * b"\0" - current_hash = hashlib.sha256(entry).digest() - return current_hash - def send_heartbeat(node, peer, b): protocol_version = 2 * b"\0" capable_version = 2 * b"\0" msg_type = b"\0" difficulty_sum = b.get_second_last_difficulty_sum().to_bytes(32, "big") - hash_value = empty_transaction_list_hash() + hash_value = b.open_transactions.get_hash(1023) if peer.partner is None: partner_ipv6 = 16 * b"\0" @@ -223,6 +216,35 @@ def transfer_block(addr, node, receive_observer, b): except NoReponseException: pass +def compare_open_transactions(addr, node, receive_observer, b): + try: + cursor = 511 + offset = 256 + subscription = receive_observer.listen((addr[0:2], "transaction hash")) + while True: + request = b"\0\0\0\0\x03" + cursor.to_bytes(2, "big") + def list_hash_condition(response): + return response["position"] == cursor + list_hash = request_retry(node, addr, request, subscription, list_hash_condition) + if list_hash["hash"] == b.open_transactions.get_hash(cursor): + cursor += max(offset, 1) + else: + cursor -= offset + if offset == 0: + break + offset //= 2 + subscription = receive_observer.listen((addr[0:2], "transaction")) + request = b"\0\0\0\0\x05" + cursor.to_bytes(2, "big") + def transaction_condition(response): + return response["position"] == cursor + remote_transaction = request_retry(node, addr, request, subscription, transaction_condition) + parsed_transaction = blockchain.Transaction.from_bytes(remote_transaction["transaction"]) + if not parsed_transaction.is_valid(): + return + b.open_transactions.add(parsed_transaction) + except NoReponseException: + pass + def receiver(node, b): receive_observer = observer.Observer() while True: @@ -254,10 +276,14 @@ def receiver(node, b): partner_port = int.from_bytes(partner_info[16:18], "big") node.add_partner((partner_ip, partner_port)) contained_difficulty_sum = int.from_bytes(msg[5:37]) + contained_transaction_hash = msg[37:69] my_difficulty_sum = b.get_second_last_difficulty_sum() if contained_difficulty_sum > my_difficulty_sum: log("beginning a block transfer ...") threading.Thread(target = transfer_block, args=(addr, node, receive_observer, b)).start() + elif contained_transaction_hash != b.open_transactions.get_hash(1023): + log("comparing open transactions ...") + threading.Thread(target = compare_open_transactions, args=(addr, node, receive_observer, b)).start() elif msg_type == 1: # block request if msg_len != 37: @@ -276,6 +302,7 @@ def receiver(node, b): # block transfer if msg_len != 297: log(f"Got a block transfer of wrong length ({msg_len} bytes from {sender}, but expected 297 bytes)") + continue block_raw = msg[5:297] new_block = b.add_block(block_raw) block_hash = new_block.own_hash @@ -283,6 +310,56 @@ def receiver(node, b): log("Got a new block") identifier = (addr[0:2], "block transfer") receive_observer.publish(identifier, block_hash) + elif msg_type == 3: + # open transaction list hash request + if msg_len != 7: + log(f"Got an open transaction list hash request of wrong length ({msg_len} bytes from {sender}, but expected 7 bytes)") + continue + list_position = int.from_bytes(msg[5:7], "big") + if list_position >= 1024: + log(f"Got an open transaction list hash request with invalid position (position {list_position} from {sender}, but must be below 1024)") + continue + list_hash = b.open_transactions.get_hash(list_position) + response = b"\0\0\0\0\x04" + list_position.to_bytes(2, "big") + list_hash + node.node_socket.sendto(response, addr) + elif msg_type == 4: + # open transaction list hash response + if msg_len != 39: + log(f"Got an open transaction list hash response of wrong length ({msg_len} bytes from {sender}, but expected 39 bytes)") + continue + event_obj = { + "position": int.from_bytes(msg[5:7], "big"), + "hash": msg[7:39], + } + identifier = (addr[0:2], "transaction hash") + receive_observer.publish(identifier, event_obj) + elif msg_type == 5: + # open transaction request + if msg_len != 7: + log(f"Got an open transaction request of wrong length ({msg_len} bytes from {sender}, but expected 7 bytes)") + continue + list_position = int.from_bytes(msg[5:7], "big") + if list_position >= 1024: + log(f"Got an open transaction request with invalid position (position {list_position} from {sender}, but must be below 1024)") + continue + transaction = b.open_transactions.get_transaction(list_position) + if transaction is None: + transaction_raw = 148 * b"\0" + else: + transaction_raw = transaction.get_transaction_raw() + response = b"\0\0\0\0\x06" + list_position.to_bytes(2, "big") + transaction_raw + node.node_socket.sendto(response, addr) + elif msg_type == 6: + # open transaction response + if msg_len != 155: + log(f"Got an open transaction list hash response of wrong length ({msg_len} bytes from {sender}, but expected 155 bytes)") + continue + event_obj = { + "position": int.from_bytes(msg[5:7], "big"), + "transaction": msg[7:155], + } + identifier = (addr[0:2], "transaction") + receive_observer.publish(identifier, event_obj) else: log(f"Got a udp message of unknown type from {sender}. (type {msg_type})") diff --git a/open_transactions.py b/open_transactions.py new file mode 100644 index 0000000..e69de29