"""Replay four unsigned transparent transaction fixtures and six allocation cases. Usage: python replay.py.txt [fixtures.json] Uses the Python 3 standard library. No node, network, keys or real LTC needed. """ import argparse from collections import Counter import hashlib import itertools import json from pathlib import Path import struct NETWORK = "litecoin-regtest-fixture" COIN = 100_000_000 QUARTER = COIN // 4 def script(tag): return "76a914" + f"{tag:02x}" * 20 + "88ac" # The scripts are artificial P2PKH templates with repeated-byte hashes. SCRIPTS = {"A": script(0x11), "B": script(0x22), "OTHER": script(0x33)} OUTPUT_SPECS = { "batch": (("OTHER", 149_990_000), ("A", QUARTER), ("B", 3 * QUARTER)), "repeated": (("A", QUARTER), ("OTHER", 10_000_000), ("A", QUARTER)), "pooled": (("A", 2 * QUARTER),), "split": (("A", 10_000_000), ("A", 15_000_000)), } def serialize(fixture_number, outputs): # Short CompactSize integers are enough for these fixed small fixtures. # The fake previous outpoint and empty scriptSig prevent any spending claim. previous_hash = bytes([0xA0 + fixture_number]) * 32 vin = previous_hash + struct.pack(" len(raw): raise ValueError("Truncated transaction") item = raw[cursor:end] cursor = end return item def count(): value = take(1)[0] if value >= 253: raise ValueError("Fixture parser supports short CompactSize only") return value version = int.from_bytes(take(4), "little", signed=True) if count() != 1: raise ValueError("Expected exactly one legacy input") previous_txid = take(32)[::-1].hex() previous_vout = int.from_bytes(take(4), "little") script_sig = take(count()).hex() sequence = int.from_bytes(take(4), "little") outputs = [] for vout in range(count()): litoshis = int.from_bytes(take(8), "little", signed=True) outputs.append({"vout": vout, "litoshis": litoshis, "script": take(count()).hex()}) locktime = int.from_bytes(take(4), "little") if cursor != len(raw): raise ValueError("Trailing transaction bytes") txid = hashlib.sha256(hashlib.sha256(raw).digest()).digest()[::-1].hex() return {"txid": txid, "version": version, "locktime": locktime, "size": len(raw), "input": {"txid": previous_txid, "vout": previous_vout, "script_sig": script_sig, "sequence": sequence}, "outputs": outputs} def invoice(invoice_id, destination, litoshis=QUARTER): return {"invoice_id": invoice_id, "script": SCRIPTS[destination], "litoshis": litoshis} CASES = ( ("two_destinations", "batch", [1, 2, 1, 0], [invoice("A-101", "A"), invoice("B-202", "B", 3 * QUARTER)]), ("one_output_two_invoices", "repeated", [0], [invoice("A-301", "A"), invoice("A-302", "A")]), ("two_outputs_two_invoices", "repeated", [0, 2, 0], [invoice("A-301", "A"), invoice("A-302", "A")]), ("two_outputs_one_invoice", "repeated", [0, 2], [invoice("A-301", "A")]), ("one_pooled_output", "pooled", [0], [invoice("A-301", "A"), invoice("A-302", "A")]), ("one_split_invoice", "split", [0, 1], [invoice("A-301", "A")]), ) def evaluate(fixture, deliveries, invoices): # Idempotency concerns the network-scoped outpoint. It does not determine # which invoice a receipt belongs to or whether it is confirmed. seen = {} for vout in deliveries: key = f"{NETWORK}:{fixture['txid']}:{vout}" seen.setdefault(key, fixture["outputs"][vout]) candidates = { key: [bill["invoice_id"] for bill in invoices if (bill["script"], bill["litoshis"]) == (out["script"], out["litoshis"])] for key, out in seen.items() } invoice_degree = Counter(name for matches in candidates.values() for name in matches) allocations = [] for key, matches in candidates.items(): if len(matches) == 1 and invoice_degree[matches[0]] == 1: allocations.append({"receipt_key": key, "invoice_id": matches[0], "litoshis": seen[key]["litoshis"]}) options = [matches for matches in candidates.values() if matches] # These tiny, deliberately limited examples can enumerate all mappings. # Count assignments covering all open invoices AND all exact-match outputs. complete_pairings = 0 if len(options) == len(invoices): complete_pairings = sum(len(set(mapping)) == len(invoices) for mapping in itertools.product(*options)) naive_txid_only_deliveries = len({fixture["txid"] for _ in deliveries}) return {"candidate_invoice_ids": candidates, "allocations": allocations, "unique_outputs": len(seen), "duplicate_deliveries": len(deliveries) - len(seen), "complete_exact_pairings": complete_pairings, "naive_txid_only_deliveries": naive_txid_only_deliveries, "allocated_litoshis": sum(row["litoshis"] for row in allocations)} def model(): fixtures = {} for index, (name, outputs) in enumerate(OUTPUT_SPECS.items(), 1): raw = serialize(index, outputs) fixtures[name] = {"raw_hex": raw.hex(), **decode(raw)} cases = [{"id": name, "fixture": fixture, "deliveries_vout": deliveries, "open_invoices": invoices, **evaluate(fixtures[fixture], deliveries, invoices)} for name, fixture, deliveries, invoices in CASES] return {"protocol": "lw-invoice-output-fixture-v1", "network_label": NETWORK, "litoshis_per_ltc": COIN, "scripts": SCRIPTS, "fixtures": fixtures, "cases": cases} def check_results(data): cases = {row["id"]: row for row in data["cases"]} batch = cases["two_destinations"] if [(row["invoice_id"], row["litoshis"]) for row in batch["allocations"]] != [ ("A-101", QUARTER), ("B-202", 3 * QUARTER)]: raise ValueError("Batch must allocate two distinct outputs") if (batch["duplicate_deliveries"], batch["naive_txid_only_deliveries"]) != (1, 1): raise ValueError("Wrong batch retry or TXID-only result") for name in ("one_output_two_invoices", "two_outputs_two_invoices", "two_outputs_one_invoice"): if cases[name]["allocations"]: raise ValueError(f"Ambiguous case allocated an invoice: {name}") if cases["two_outputs_two_invoices"]["complete_exact_pairings"] != 2: raise ValueError("Expected two indistinguishable complete assignments") for name in ("one_pooled_output", "one_split_invoice"): if any(cases[name]["candidate_invoice_ids"].values()): raise ValueError(f"Exact-output model must defer special policy: {name}") def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("fixture_file", nargs="?", type=Path, default=Path(__file__).with_name("fixtures.json")) args = parser.parse_args() current = model() check_results(current) frozen = json.loads(args.fixture_file.read_text(encoding="utf-8")) if current != frozen: raise SystemExit("Fixture bytes or case results differ from the frozen export.") print(f"PASS: {len(current['fixtures'])} transactions, {len(current['cases'])} allocation cases") if __name__ == "__main__": main()