diff --git a/pyproject.toml b/pyproject.toml index c1a9717..b503d83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ pdoc = "^14" [tool.poetry.scripts] demo = "simtfl.demo:run" +bc-demo = "simtfl.bc.demo:run" [build-system] requires = ["poetry-core"] diff --git a/simtfl/bc/__init__.py b/simtfl/bc/__init__.py new file mode 100644 index 0000000..c8775a5 --- /dev/null +++ b/simtfl/bc/__init__.py @@ -0,0 +1,299 @@ +""" +Abstractions for best-chain transactions, contexts, and blocks. + +The transaction protocol is assumed to have a Bitcoin-like TXO-based +transparent component, and a Zcash-like note-based shielded component. + +A block consists of a coinbase transaction which can issue new funds, +and zero or more non-coinbase transactions which cannot issue funds. +Each block chains to its parent block. + +A transaction pays a transparent fee. Each block's coinbase transaction +collects the fees paid by the other transactions in the block. + +The simulation of the shielded protocol does not attempt to model any +actual privacy properties. +""" + +from collections import deque +from dataclasses import dataclass +from enum import Enum, auto +from itertools import chain +from sys import version_info + +from ..util import Unique + + +class BlockHash(Unique): + """Unique value representing a best-chain block hash.""" + pass + + +class BCTransaction: + """A transaction for a best-chain protocol.""" + + @dataclass(frozen=True) + class _TXO: + tx: 'BCTransaction' + index: int + value: int + + @dataclass(eq=False) + class _Note(Unique): + """ + A shielded note. Unlike in the actual protocol, we conflate notes, note + commitments, and nullifiers. This will be sufficient because we don't + need to maintain any actual privacy. + + This is not a frozen dataclass; its identity is important, and models the + fact that each note has a unique commitment and nullifier in the actual + protocol. + """ + value: int + + def __init__(self, transparent_inputs, transparent_output_values, shielded_inputs, + shielded_output_values, fee, anchor=None, issuance=0): + """ + Constructs a `BCTransaction` with the given transparent inputs, transparent + output values, anchor, shielded inputs, shielded output values, fee, and + (if it is a coinbase transaction) issuance. + + The elements of `transparent_inputs` are TXO objects obtained from the + `transparent_output` method of another `BCTransaction`. The elements of + `shielded_inputs` are Note objects obtained from the `shielded_output` + method of another `BCTransaction`. The TXO and Note classes are private, + and these objects should not be constructed directly. + + The anchor is modelled as a `BCContext` such that + `anchor.can_spend(shielded_inputs)`. If there are no shielded inputs, + `anchor` must be `None`. The anchor object must not be modified after + passing it to this constructor (copy it if necessary). + + For a coinbase transaction, pass `[]` for `transparent_inputs` and + `shielded_inputs`, and pass `fee` as a negative value of magnitude equal + to the total amount of fees paid by other transactions in the block. + """ + assert issuance >= 0 + coinbase = len(transparent_inputs) + len(shielded_inputs) == 0 + assert fee >= 0 or coinbase + assert issuance == 0 or coinbase + assert all((v >= 0 for v in chain(transparent_output_values, shielded_output_values))) + assert all((isinstance(txin, self._TXO) for txin in transparent_inputs)) + assert all((isinstance(note, self._Note) for note in shielded_inputs)) + assert ( + sum((txin.value for txin in transparent_inputs)) + + sum((note.value for note in shielded_inputs)) + + issuance == + sum(transparent_output_values) + + sum(shielded_output_values) + + fee + ) + assert anchor is None if len(shielded_inputs) == 0 else anchor.can_spend(shielded_inputs) + + self.transparent_inputs = transparent_inputs + self.transparent_outputs = [self._TXO(self, i, v) + for (i, v) in enumerate(transparent_output_values)] + self.shielded_inputs = shielded_inputs + self.shielded_outputs = [self._Note(v) for v in shielded_output_values] + self.fee = fee + self.anchor = anchor + self.issuance = issuance + + def transparent_input(self, index): + """Returns the transparent input TXO with the given index.""" + return self.transparent_inputs[index] + + def transparent_output(self, index): + """Returns the transparent output TXO with the given index.""" + return self.transparent_outputs[index] + + def shielded_input(self, index): + """Returns the shielded input note with the given index.""" + return self.shielded_inputs[index] + + def shielded_output(self, index): + """Returns the shielded output note with the given index.""" + return self.shielded_outputs[index] + + def is_coinbase(self): + """ + Returns `True` if this is a coinbase transaction (it has no inputs). + """ + return len(self.transparent_inputs) + len(self.shielded_inputs) == 0 + + +class Spentness(Enum): + """The spentness status of a note.""" + Unspent = auto() + """The note is unspent.""" + Spent = auto() + """The note is spent.""" + + +class BCContext: + """ + A context that allows checking transactions for contextual validity in a + best-chain protocol. + """ + + assert version_info >= (3, 7), "This code relies on insertion-ordered dicts." + + def __init__(self): + """Constructs an empty `BCContext`.""" + self.transactions = deque() # of BCTransaction + self.utxo_set = set() # of BCTransaction._TXO + + # Since dicts are insertion-ordered, this models the sequence in which + # notes are committed as well as their spentness. + self.notes = {} # Note -> Spent | Unspent + + self.total_issuance = 0 + + def committed_notes(self): + """ + Returns a list of (`Note`, `Spentness`) for notes added to this context, + preserving the commitment order. + """ + return list(self.notes.items()) + + def can_spend(self, tospend): + """Can all of the notes in `tospend` be spent in this context?""" + return all((self.notes.get(note) == Spentness.Unspent for note in tospend)) + + def _check(self, tx): + """ + Checks whether `tx` is valid. To avoid recomputation, this returns + a pair of the validity, and the set of transparent inputs of `tx`. + """ + txins = set(tx.transparent_inputs) + valid = txins.issubset(self.utxo_set) and self.can_spend(tx.shielded_inputs) + return (valid, txins) + + def is_valid(self, tx): + """Is `tx` valid in this context?""" + return self._check(tx)[0] + + def add_if_valid(self, tx): + """ + If `tx` is valid in this context, add it to the context and return `True`. + Otherwise leave the context unchanged and return `False`. + """ + (valid, txins) = self._check(tx) + if valid: + self.utxo_set -= txins + self.utxo_set |= set(tx.transparent_outputs) + + for note in tx.shielded_inputs: + self.notes[note] = Spentness.Spent + for note in tx.shielded_outputs: + assert note not in self.notes + self.notes[note] = Spentness.Unspent + + self.total_issuance += tx.issuance + self.transactions.append(tx) + + return valid + + def copy(self): + """Returns an independent copy of this `BCContext`.""" + ctx = BCContext() + ctx.transactions = self.transactions.copy() + ctx.utxo_set = self.utxo_set.copy() + ctx.notes = self.notes.copy() + ctx.total_issuance = self.total_issuance + return ctx + + +class BCBlock: + """A block in a best-chain protocol.""" + + def __init__(self, parent, added_score, transactions, allow_invalid=False): + """ + Constructs a `BCBlock` with the given parent block, score relative to the + parent, and sequence of transactions. `transactions` must not be modified + after passing it to this constructor (copy it if necessary). + If `allow_invalid` is set, the block need not be valid. + Use `parent=None` to construct the genesis block. + """ + assert all((isinstance(tx, BCTransaction) for tx in transactions)) + self.parent = parent + self.score = (0 if parent is None else self.parent.score) + added_score + self.transactions = transactions + self.hash = BlockHash() + if not allow_invalid: + self.assert_noncontextually_valid() + + def assert_noncontextually_valid(self): + """Assert that non-contextual consensus rules are satisfied for this block.""" + assert len(self.transactions) > 0 + assert self.transactions[0].is_coinbase() + assert not any((tx.is_coinbase() for tx in self.transactions[1:])) + assert sum((tx.fee for tx in self.transactions)) == 0 + + def is_noncontextually_valid(self): + """Are non-contextual consensus rules satisfied for this block?""" + try: + self.assert_noncontextually_valid() + return True + except AssertionError: + return False + + +@dataclass +class BCProtocol: + """A best-chain protocol.""" + + Transaction: type[object] = BCTransaction + """The type of transactions for this protocol.""" + + Context: type[object] = BCContext + """The type of contexts for this protocol.""" + + Block: type[object] = BCBlock + """The type of blocks for this protocol.""" + + +__all__ = ['BCTransaction', 'BCContext', 'BCBlock', 'BCProtocol', 'BlockHash', 'Spentness'] + + +import unittest + + +class TestBC(unittest.TestCase): + def test_basic(self): + ctx = BCContext() + coinbase_tx0 = BCTransaction([], [10], [], [], 0, issuance=10) + self.assertTrue(ctx.add_if_valid(coinbase_tx0)) + genesis = BCBlock(None, 1, [coinbase_tx0]) + self.assertEqual(genesis.score, 1) + self.assertEqual(ctx.total_issuance, 10) + + coinbase_tx1 = BCTransaction([], [6], [], [], -1, issuance=5) + spend_tx = BCTransaction([coinbase_tx0.transparent_output(0)], [9], [], [], 1) + self.assertTrue(ctx.add_if_valid(coinbase_tx1)) + self.assertTrue(ctx.add_if_valid(spend_tx)) + block1 = BCBlock(genesis, 1, [coinbase_tx1, spend_tx]) + self.assertEqual(block1.score, 2) + self.assertEqual(ctx.total_issuance, 15) + + coinbase_tx2 = BCTransaction([], [6], [], [], -1, issuance=5) + shielding_tx = BCTransaction([coinbase_tx1.transparent_output(0), spend_tx.transparent_output(0)], + [], [], [8, 6], 1) + self.assertTrue(ctx.add_if_valid(coinbase_tx2)) + self.assertTrue(ctx.add_if_valid(shielding_tx)) + block2 = BCBlock(block1, 2, [coinbase_tx2, shielding_tx]) + block2_anchor = ctx.copy() + self.assertEqual(block2.score, 4) + self.assertEqual(ctx.total_issuance, 20) + + coinbase_tx3 = BCTransaction([], [7], [], [], -2, issuance=5) + shielded_tx = BCTransaction([], [], [shielding_tx.shielded_output(0)], [7], 1, + anchor=block2_anchor) + deshielding_tx = BCTransaction([], [5], [shielding_tx.shielded_output(1)], [], 1, + anchor=block2_anchor) + self.assertTrue(ctx.add_if_valid(coinbase_tx3)) + self.assertTrue(ctx.add_if_valid(shielded_tx)) + self.assertTrue(ctx.add_if_valid(deshielding_tx)) + block3 = BCBlock(block2, 3, [coinbase_tx3, shielded_tx, deshielding_tx]) + self.assertEqual(block3.score, 7) + self.assertEqual(ctx.total_issuance, 25) diff --git a/simtfl/bc/demo.py b/simtfl/bc/demo.py new file mode 100644 index 0000000..6c8929d --- /dev/null +++ b/simtfl/bc/demo.py @@ -0,0 +1,8 @@ +import unittest + + +def run(): + """ + Runs the demo. + """ + unittest.main(module='simtfl.bc', verbosity=2) diff --git a/simtfl/node.py b/simtfl/node.py index a714bfd..8066d47 100644 --- a/simtfl/node.py +++ b/simtfl/node.py @@ -162,6 +162,7 @@ def run(self): # is sent at time 3 and received at time 11. yield from self.send(0, PayloadMessage(3), delay=8) + class TestFramework(unittest.TestCase): def _test_node(self, receiver_node, expected): network = Network(Environment()) diff --git a/simtfl/util.py b/simtfl/util.py index 417b6f4..12ab24d 100644 --- a/simtfl/util.py +++ b/simtfl/util.py @@ -9,3 +9,17 @@ def skip(): """ # Make this a generator. yield from [] + + +class Unique: + """ + Represents a unique value. + + Instances of this class are hashable. When subclassing as a dataclass, use + `@dataclass(eq=False)` to preserve hashability. + """ + def __eq__(self, other): + return self == other + + def __hash__(self): + return id(self)