From 2bc03069d46ba79868648145a736ebf002ebdaf9 Mon Sep 17 00:00:00 2001 From: Dhananjay Nene Date: Wed, 13 Oct 2021 15:44:28 +0530 Subject: [PATCH] Created structure for basic ledger operations using a database --- docs/trust-ledger-characteristics.md | 3 + ppl/database/__init__.py | 0 ppl/database/model.py | 163 ++++++++++++++++++++++++ ppl/database/typedecorators/__init__.py | 0 ppl/database/typedecorators/guid.py | 41 ++++++ ppl/database/typedecorators/json.py | 26 ++++ ppl/types/contract.py | 56 ++++++++ ppl/types/iou.py | 3 + ppl/types/state.py | 4 +- ppl/types/wallet.py | 16 --- requirements.txt | 3 + tests/early_test.py | 6 +- tests/test_ledger_operations.py | 63 +++++++++ tests/utils/__init__.py | 0 tests/utils/url.py | 1 + 15 files changed, 367 insertions(+), 18 deletions(-) create mode 100644 docs/trust-ledger-characteristics.md create mode 100644 ppl/database/__init__.py create mode 100644 ppl/database/model.py create mode 100644 ppl/database/typedecorators/__init__.py create mode 100644 ppl/database/typedecorators/guid.py create mode 100644 ppl/database/typedecorators/json.py create mode 100644 ppl/types/contract.py create mode 100644 tests/test_ledger_operations.py create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/url.py diff --git a/docs/trust-ledger-characteristics.md b/docs/trust-ledger-characteristics.md new file mode 100644 index 0000000..7a31275 --- /dev/null +++ b/docs/trust-ledger-characteristics.md @@ -0,0 +1,3 @@ +# Important trust ledger characteristics + +## Agnostic \ No newline at end of file diff --git a/ppl/database/__init__.py b/ppl/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ppl/database/model.py b/ppl/database/model.py new file mode 100644 index 0000000..bf1af0f --- /dev/null +++ b/ppl/database/model.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import enum +import itertools +import uuid +from datetime import datetime +from typing import Optional, Callable, Tuple + +from sqlalchemy import Column, Integer, String, ForeignKey, DATETIME, Enum +from sqlalchemy.orm import declarative_base, relationship + +from ppl.database.typedecorators.guid import GUID +from ppl.database.typedecorators.json import JsonData +from ppl.types.state import StateType + +Base = declarative_base() + + +class Entity(Base): + __tablename__ = 'entities' + id = Column(GUID, primary_key=True) + name = Column(String(64)) + + def __init__(self, id, name): + self.id = id + self.name = name + + +class Schema(Base): + __tablename__ = "ppl_schemas" + id = Column(GUID, primary_key=True) + name = Column(String(64), nullable=False) + version = Column(Integer, nullable=False) + entity_id = Column(GUID, ForeignKey('entities.id')) + + def __init__(self, id, name, version): + self.id = id + self.name = name + self.version = version + + +class SchemaState(Base): + __tablename__ = "schema_states" + id = Column(GUID, primary_key=True) + name = Column(String(64)) + schema_id = Column(GUID, ForeignKey("ppl_schemas.id")) + + def __init__(self, id, name, schema_id): + self.id = id + self.name = name + self.schema_id = schema_id + + +class Wallet(Base): + __tablename__ = 'wallets' + next_id = itertools.count().__next__ + + id = Column(GUID, primary_key=True) + handle = Column(String(16)) + public_key = Column(String(512)) + + def __init__(self, id, handle, public_key): + self.id = id if id else Wallet.next_id() + self.handle = handle + self.public_key = public_key + + +class StateValidity(enum.Enum): + Proposed = "P" + Active = "A" + Extinguished = "E" + + +class State(Base): + __tablename__ = 'states' + + next_id = itertools.count().__next__ + + id = Column(GUID, primary_key=True) + logical_id = Column(GUID) + version = Column(Integer) + prior_id = Column(GUID, nullable=True) + creator_id = Column(GUID, ForeignKey("transactions.id"), nullable=False) + destroyer_id = Column(GUID, ForeignKey("transactions.id"), nullable=True) + created = Column(DATETIME) + state_type = Column(Enum(StateType)) + validity = Column(Enum(StateValidity)) + public = Column(JsonData(4000)) + proof = Column(JsonData(4000)) + + creator = relationship("Transaction", foreign_keys=[creator_id]) + destroyer = relationship("Transaction", foreign_keys=[destroyer_id]) + + __mapper_args__ = { + 'polymorphic_on': state_type, + 'polymorphic_identity': StateType.State + } + + def __init__(self, id: uuid.UUID, logical_id: uuid.UUID, version: int, created: datetime, + validity: StateValidity, prior_id: Optional[uuid.UUID], + public: dict, proofs: dict): + self.id = id + self.logical_id = logical_id + self.version = version + self.created = created + self.prior_id = prior_id + self.validity = validity + self.public = public + self.proofs = proofs + + def mutate(self, id: uuid.UUID, new_transaction: Transaction, created: datetime, mutator: Callable[[dict, dict], Tuple[dict, dict]]) -> State: + new_public, new_proofs = mutator(self.public, self.proofs) + self.validity = StateValidity.Extinguished + new_state = State(id, self.logical_id, self.version + 1, created, StateValidity.Active, self.id, new_public, + new_proofs) + new_transaction.destroy_state(self) + new_transaction.create_state(new_state) + return new_state + + +class IOU(State): + __tablename__ = 'states' + + __mapper_args__ = { + 'polymorphic_identity': StateType.IOU + } + + def __init__(self, id: uuid.UUID, logical_id: uuid.UUID, version: int, created: datetime, + validity: StateValidity, prior_id: Optional[uuid.UUID], + public: dict, proofs: dict): + super().__init__(id, logical_id, version, created, validity, prior_id, public, proofs) + + +class Contract(State): + __tablename__ = 'states' + + __mapper_args__ = { + 'polymorphic_identity': StateType.Contract + } + + +class Transaction(Base): + __tablename__ = 'transactions' + + id = Column(GUID, primary_key=True) + + + def __init__(self, id: uuid.UUID): + self.id = id + + def create_state(self, state: State): + if state.creator_id is None: + state.creator_id = self.id + else: + raise Exception("State already initialised by another transaction") + + def destroy_state(self, state: State): + if state.creator_id is None: + raise Exception("State not yet created") + elif state.destroyer_id is not None: + raise Exception("State has already been destroyed by another transaction") + else: + state.destroyer_id = self.id diff --git a/ppl/database/typedecorators/__init__.py b/ppl/database/typedecorators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ppl/database/typedecorators/guid.py b/ppl/database/typedecorators/guid.py new file mode 100644 index 0000000..44c02df --- /dev/null +++ b/ppl/database/typedecorators/guid.py @@ -0,0 +1,41 @@ +from sqlalchemy.types import TypeDecorator, CHAR +from sqlalchemy.dialects.postgresql import UUID +import uuid + +# Copied from https://docs.sqlalchemy.org/en/14/core/custom_types.html#backend-agnostic-guid-type + +class GUID(TypeDecorator): + """Platform-independent GUID type. + + Uses PostgreSQL's UUID type, otherwise uses + CHAR(32), storing as stringified hex values. + + """ + impl = CHAR + cache_ok = True + + def load_dialect_impl(self, dialect): + if dialect.name == 'postgresql': + return dialect.type_descriptor(UUID()) + else: + return dialect.type_descriptor(CHAR(32)) + + def process_bind_param(self, value, dialect): + if value is None: + return value + elif dialect.name == 'postgresql': + return str(value) + else: + if not isinstance(value, uuid.UUID): + return "%.32x" % uuid.UUID(value).int + else: + # hexstring + return "%.32x" % value.int + + def process_result_value(self, value, dialect): + if value is None: + return value + else: + if not isinstance(value, uuid.UUID): + value = uuid.UUID(value) + return value diff --git a/ppl/database/typedecorators/json.py b/ppl/database/typedecorators/json.py new file mode 100644 index 0000000..eebe36e --- /dev/null +++ b/ppl/database/typedecorators/json.py @@ -0,0 +1,26 @@ +from sqlalchemy import TypeDecorator, VARCHAR +import json + +class JsonData(TypeDecorator): + """Represents an immutable structure as a json-encoded string. + + Usage:: + + JSONEncodedDict(255) + + """ + + impl = VARCHAR + + cache_ok = True + + def process_bind_param(self, value, dialect): + if value is not None: + value = json.dumps(value) + + return value + + def process_result_value(self, value, dialect): + if value is not None: + value = json.loads(value) + return value \ No newline at end of file diff --git a/ppl/types/contract.py b/ppl/types/contract.py new file mode 100644 index 0000000..a1f5383 --- /dev/null +++ b/ppl/types/contract.py @@ -0,0 +1,56 @@ +from decimal import Decimal + +from ppl.types.iou import ContractType +from ppl.types.state import StateType, SerialisedState, State +from ppl.types.value_store import ValueStore, UOM +from ppl.types.wallet import Wallet + + +class Contract(ValueStore): + state_type = StateType.Contract + + def __init__(self, state_id: int, contract_type: ContractType, own_wallet: Wallet, uom: UOM, amount: Decimal): + super().__init__(state_id, uom, amount) + self.contract_type = contract_type + self.own_wallet = own_wallet + + def __str__(self): + return "Contract {}->{}:{} {}".format(self.contract_type, self.own_wallet, self.uom, self.amount) + + def __eq__(self, other): + return isinstance(other, Contract) \ + and self.contract_type.value == other.contract_type.value \ + and self.own_wallet.id == other.own_wallet.id \ + and self.uom == other.uom \ + and self.amount == other.amount + + +def serialise(self) -> SerialisedState: + """Convert state data into a generic serialised state for further handling in an abstract way by the platform""" + return SerialisedState( + self.state_type.value, + self.state_id, + { + "uom": self.uom.value, + "amount": self.amount, + "contract_type": self.contract_type.value, + "own_wallet_id": self.own_wallet.id, + }, { + + } + ) + + +@staticmethod +def deserialise(ecosystem: 'Ecosystem', dct: SerialisedState): + """Reconstruct an IOU from a serialised state""" + instance = Contract(dct["state_id"], + Contract(dct["public"]["contract_type"]), + ecosystem.get_wallet_for_id(dct["public"]["own_wallet_id"]), + UOM(dct["public"]["uom"]), + Decimal(dct["public"]["amount"])) + instance.state_id = dct["state_id"] + return instance + + +State.deserialisers[StateType.IOU.value] = deserialise diff --git a/ppl/types/iou.py b/ppl/types/iou.py index 31f8cc6..a240faf 100644 --- a/ppl/types/iou.py +++ b/ppl/types/iou.py @@ -15,6 +15,9 @@ class IOUType(Enum): AccountDeposit = 2 Loan = 3 +class ContractType(Enum): + """A subclass of a State. These can be of multiple types""" + Escrow = 1 class IOU(ValueStore): """An IOU is a subclass of State which expresses a liability from a `from_wallet` to a `to_wallet` for a given diff --git a/ppl/types/state.py b/ppl/types/state.py index 5c273af..8a69bb7 100644 --- a/ppl/types/state.py +++ b/ppl/types/state.py @@ -5,7 +5,9 @@ class StateType(Enum): """Different states can be stored on the ledger. StateType disambiguates which state is being stored""" - IOU = 1 + State = "STT" + IOU = "IOU" + Contract = "CON" class SerialisedState: diff --git a/ppl/types/wallet.py b/ppl/types/wallet.py index aafe288..e69de29 100644 --- a/ppl/types/wallet.py +++ b/ppl/types/wallet.py @@ -1,16 +0,0 @@ -import itertools - -from ppl.types.wallet_provider import WalletProvider -from ppl.utils.crypto import generate_keypair - - -class Wallet: - next = itertools.count().__next__ - - def __init__(self, wallet_id: int, code: str): - self.id = wallet_id - self.code = code - self.key = generate_keypair("w_{}_key".format(wallet_id)) - - def __str__(self): - return "W({}:{})".format(self.id, self.code) diff --git a/requirements.txt b/requirements.txt index 1a1b29f..ac6f1f5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ git+https://github.com/meilof/pysnark jwcrypto +sqlalchemy==1.4.22 +pymysql==1.0.2 +cryptography==3.4.7 # python-libsnark # required if we decide to use use libsnark diff --git a/tests/early_test.py b/tests/early_test.py index 093f40e..71ac606 100644 --- a/tests/early_test.py +++ b/tests/early_test.py @@ -46,7 +46,7 @@ def test_currency_serialisation(self): serialised = json.dumps(one_lakh_issuance.serialise().to_json(), cls=DineroEncoder) data = json.loads(serialised) deserialised_state = deserialise_state(ecosystem, data) - log.debug("Deserialised currency object {}".format(deserialised_state)) + print("Deserialised currency object {}".format(deserialised_state)) self.assertEqual(one_lakh_issuance, deserialised_state, "Serialised currency object is not the same as deserialised currency") @@ -54,6 +54,10 @@ def test_currency_serialisation(self): transaction = Transaction(None, created_states, "RBI issues one lakh \u20b9.") mi.record(transaction) + def test_contract_serialisation(self): + one_lakh_issuance = IOU(State.next_id(), IOUType.Currency, cb.main_wallet, cb.main_wallet, UOM.Currency_INR, + Decimal("100000.00")) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_ledger_operations.py b/tests/test_ledger_operations.py new file mode 100644 index 0000000..3869ca0 --- /dev/null +++ b/tests/test_ledger_operations.py @@ -0,0 +1,63 @@ +import unittest +import uuid +from datetime import datetime + +from cryptography.hazmat.backends import default_backend as crypto_default_backend +from cryptography.hazmat.primitives import serialization as crypto_serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from ppl.database.model import Base, Entity, Schema, SchemaState, State, StateValidity, IOU, Transaction, Wallet +from tests.utils.url import url + +engine = create_engine(url, echo=True, future=True) +Session = sessionmaker(bind=engine) + + +class TestLedgerOperations(unittest.TestCase): + def test_basic_ledger_operations(self): + Base.metadata.bind = engine + with engine.connect() as conn: + Base.metadata.drop_all() + Base.metadata.create_all() + + keypair = rsa.generate_private_key( + backend=crypto_default_backend(), + public_exponent=65537, + key_size=2048 + ) + private_key = keypair.private_bytes( + crypto_serialization.Encoding.PEM, + crypto_serialization.PrivateFormat.PKCS8, + crypto_serialization.NoEncryption()) + public_key = keypair.public_key().public_bytes( + crypto_serialization.Encoding.OpenSSH, + crypto_serialization.PublicFormat.OpenSSH + ) + rbi = Entity(uuid.uuid4(), "Reserve Bank of India") + cbdc_schema = Schema(uuid.uuid4(), "RBI CBDC", 1) + iou_schema_state = SchemaState(uuid.uuid4(), "IOU", cbdc_schema) + cbdc_wallet = Wallet(uuid.uuid4(), "rbi_cbdc", public_key) + transaction = Transaction(uuid.uuid4()) + some_state = State(uuid.uuid4(), uuid.uuid4(), 1, datetime.now(), StateValidity.Active, None, + {"foo": "bar1"}, + {"buz": "fiz1"}) + + session = Session() + + session.add_all((rbi, cbdc_schema, cbdc_wallet, transaction, some_state)) + transaction.create_state(some_state) + session.commit() + new_transaction = Transaction(uuid.uuid4()) + mutated_state = some_state.mutate(uuid.uuid4(), new_transaction, datetime.now(), + lambda p, q: ({"foo": "bar2"}, {"buz": "fiz2"})) + session.add_all((new_transaction, mutated_state,)) + session.commit() + + iou_transaction = Transaction(uuid.uuid4()) + iou = IOU(uuid.uuid4(), uuid.uuid4(), 1, datetime.now(), StateValidity.Active, None, {"iou1": "value1"}, + {"iou2": "value2"}) + iou_transaction.create_state(iou) + session.add_all((iou_transaction, iou,)) + session.commit() diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/url.py b/tests/utils/url.py new file mode 100644 index 0000000..60744f7 --- /dev/null +++ b/tests/utils/url.py @@ -0,0 +1 @@ +url="mysql+pymysql://ppltest:PPLTest#123@localhost/ppltest" \ No newline at end of file