usawa

Unnamed repository; edit this file 'description' to name the repository.
Info | Log | Files | Refs | Submodules | LICENSE

commit 82b88dbe27b06b336bf716491a4f617c57950cdf
parent f3f2c3c7b5f5f770b785b7bdbd29a1c022c63594
Author: lash <dev@holbrook.no>
Date:   Thu,  1 Jan 2026 11:51:53 +0100

Rename package to usawa, add docs for ledger

Diffstat:
Mdummy/create.py | 2+-
Mdummy/demo.py | 2+-
Ddummy/svcontas/constant.py | 4----
Ddummy/svcontas/entry.py | 268-------------------------------------------------------------------------------
Ddummy/svcontas/ledger.py | 302------------------------------------------------------------------------------
Ddummy/svcontas/unit.py | 94-------------------------------------------------------------------------------
Mdummy/tests/entry.py | 4++--
Mdummy/tests/ledger.py | 4++--
Mdummy/tests/store.py | 4++--
Mdummy/tests/test.xml | 2+-
Rdummy/svcontas/__init__.py -> dummy/usawa/__init__.py | 0
Adummy/usawa/cmd.py | 8++++++++
Adummy/usawa/constant.py | 4++++
Rdummy/svcontas/crypto.py -> dummy/usawa/crypto.py | 0
Adummy/usawa/entry.py | 270+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rdummy/svcontas/error.py -> dummy/usawa/error.py | 0
Adummy/usawa/ledger.py | 413+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adummy/usawa/runnable/server.py | 35+++++++++++++++++++++++++++++++++++
Rdummy/svcontas/store.py -> dummy/usawa/store.py | 0
Adummy/usawa/unit.py | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rdummy/svcontas/xml.py -> dummy/usawa/xml.py | 0
21 files changed, 833 insertions(+), 677 deletions(-)

diff --git a/dummy/create.py b/dummy/create.py @@ -6,7 +6,7 @@ import lxml.etree import confini import nacl.signing -from svcontas import Entry, EntryPart, DemoWallet, ACL, get_units, init_ledger +from usawa import Entry, EntryPart, DemoWallet, ACL, get_units, init_ledger logging.basicConfig(level=logging.DEBUG) logg = logging.getLogger() diff --git a/dummy/demo.py b/dummy/demo.py @@ -4,7 +4,7 @@ import datetime from lxml import etree -from svcontas import load, get_units, init_ledger +from usawa import load, get_units, init_ledger logging.basicConfig(level=logging.DEBUG) logg = logging.getLogger() diff --git a/dummy/svcontas/constant.py b/dummy/svcontas/constant.py @@ -1,4 +0,0 @@ -DEFAULTPARENT = b'\x00' * 64 -NS = 'http://svcontas.defalsify.org/' -NAMESPACES = {None: NS} -NSPREFIX = '{' + NS + '}' diff --git a/dummy/svcontas/entry.py b/dummy/svcontas/entry.py @@ -1,268 +0,0 @@ -import enum -import logging -import datetime -import uuid -import hashlib - -from lxml import etree -import rencode - -from .constant import DEFAULTPARENT, NSPREFIX -from .crypto import DemoWallet -from .error import ACLError, VerifyError -from .xml import nsmap - -logg = logging.getLogger('svcontas.entry') - - -class KeyStoreFormat(enum.IntEnum): - LITERAL = 0 - INDEXED = 1 - - -class EntryPart: - - def __init__(self, typ, account, amount, src=False): - self.typ = typ - self.account = account - self.amount = amount - self.issrc = src - - - @staticmethod - def from_tree(tree, src=False): - typ = tree.get('type') - amount = int(tree.find('amount', namespaces=nsmap()).text) - account = tree.find('account', namespaces=nsmap()).text - return EntryPart(typ, account, amount, src=src) - - - def apply_tree(self, tree): - tag = 'dst' - if self.issrc: - tag = 'src' - part = etree.Element(tag, type=self.typ) - o = etree.Element('account') - o.text = self.account - part.append(o) - - o = etree.Element('amount') - o.text = str(self.amount) - part.append(o) - - tree.append(part) - return part - - - def __str__(self): - pfx = 'dst' - if self.issrc: - pfx = 'src' - return '[{}] {}:{} {}'.format(pfx, self.typ, self.account, self.amount) - - -class Entry: - - # TODO: parent only 0 if serial 0 - def __init__(self, src, dst, unit, serial, tx_date, ref=None, description=None, parent=None, tx_datereg=None): - if isinstance(parent, str): - parent = bytes.fromhex(parent) - elif parent == None: - parent = DEFAULTPARENT - elif len(parent) != 64: - raise ValueError('invalid parent hash') - if ref == None: - ref = str(uuid.uuid4()) - self.ref = ref - self.parent = parent - self.unit = unit - self.serial = serial - self.dt = tx_date - if tx_datereg == None: - tx_datereg = datetime.datetime.now() - self.dtreg = tx_datereg - self.attachment = [] - self.sigs = {} - self.description = description - self.src = src - self.dst = dst - - - def attach(self, mime, algo, digest, description=None, slug=None): - self.attachment.append((mime, algo, digest, description, slug,)) - - - def add_signature(self, keyid, sigdata): - self.sigs[keyid] = sigdata - - - @staticmethod - def from_tree(tree, unitindex, min=0): - o = tree.find('data', namespaces=nsmap()) - serial = int(o.find('serial', namespaces=nsmap()).text) - if min > serial: - raise ValueError('entry serial preceeds ledger') - unit = o.find('unit', namespaces=nsmap()).text - unitindex.sym(unit) - - ref = o.find('ref', namespaces=nsmap()).text - parent = o.find('parent', namespaces=nsmap()).text - description = o.find('description', namespaces=nsmap()) - if description != None: - description = description.text - dt = datetime.date.fromisoformat(o.find('date', namespaces=nsmap()).text) - dtreg = datetime.datetime.strptime(o.find('dateTimeRegistered', namespaces=nsmap()).text, '%Y-%m-%dT%H:%M:%SZ') - src = EntryPart.from_tree(tree.find('src', namespaces=nsmap()), src=True) - dst = EntryPart.from_tree(tree.find('dst', namespaces=nsmap())) - - r = Entry(src, dst, unit, serial, dt, ref=ref, parent=parent, tx_datereg=dtreg, description=description) - for sig in tree.iter(NSPREFIX + 'sig'): - r.add_signature(sig.get('keyid'), bytes.fromhex(sig.text)) - return r - - - def serialize(self): - src = [self.src.typ, self.src.account, self.src.amount] - dst = [self.dst.typ, self.dst.account, self.dst.amount] - d = [ - self.parent, - self.serial, - self.ref, - self.dtreg.strftime('%Y%m%d%H%M%S'), - self.dt.strftime('%Y%m%d'), - self.unit, - self.description, - src, - dst, - ] - logg.debug('serialize entry {}'.format(d)) - return rencode.dumps(d) - - - @staticmethod - def deserialize(data): - v = rencode.loads(data) - parent = v[0] - serial = v[1] - ref = v[2] - date_reg = datetime.datetime.strptime(v[3].decode('utf-8'), '%Y%m%d%H%M%S') - date = datetime.datetime.strptime(v[4].decode('utf-8'), '%Y%m%d') - unit = v[5] - description = v[6] - src_data = v[7] - dst_data = v[8] - src = EntryPart(src_data[0], src_data[1], src_data[2], src=True) - dst = EntryPart(dst_data[0], dst_data[1], dst_data[2]) - return Entry(src, dst, unit, serial, date, ref=ref, description=description, parent=parent, tx_datereg=date_reg) - - - def sum(self): - b = self.serialize() - h = hashlib.new('sha512') - h.update(b) - return (h.digest(), b) - - - def sign(self, wallet): - (z, b) = self.sum() - r = wallet.sign(z) - pubk_hx = wallet.pubkey().hex() - self.sigs[pubk_hx] = r - logg.debug('added signature from key {}'.format(pubk_hx)) - return (z, r, b,) - - - def wrap(self, wallet=None): - digest = None - sig = None - data = None - if wallet != None: - (digest, sig, data) = self.sign(wallet) - hdr = [] - sigs = [] - for k in self.sigs: - hdr.append([ - KeyStoreFormat.LITERAL.value, - bytes.fromhex(k) - ]) - sigs.append(self.sigs[k]) - - if len(sigs) == 0: - raise VerifyError() - - d = [ - hdr, - sigs, - data, - ] - return rencode.dumps(d) - - - @staticmethod - def unwrap(data, acl=None): - v = rencode.loads(data) - # TODO: demo only takes into account single signature - pubkey_bytes = v[0][0][1] - if acl != None: - label = None - try: - label = acl.have(pubkey_bytes) - except KeyError: - raise ACLError() - if not acl.may(label, 0x01): - raise ACLError() - wallet = DemoWallet(publickey=pubkey_bytes) - # TODO: demo only takes into account single signature - sig = v[1][0] - entry = Entry.deserialize(v[2]) - (z, b) = entry.sum() - wallet.verify(z, sig) - return entry - - - - def to_tree(self): - #tree = etree.Element('entry', type=self.typ) - tree = etree.Element('entry') - data = etree.Element('data') - - o = etree.Element('parent') - o.text = self.parent.hex() - data.append(o) - - o = etree.Element('ref') - o.text = self.ref - data.append(o) - - o = etree.Element('serial') - o.text = str(self.serial) - data.append(o) - - o = etree.Element('unit') - o.text = self.unit - data.append(o) - - o = etree.Element('date') - o.text = self.dt.strftime('%Y-%m-%d') - data.append(o) - - o = etree.Element('dateTimeRegistered') - o.text = self.dtreg.strftime('%Y-%m-%dT%H:%M:%SZ') - data.append(o) - - if self.description: - o = etree.Element('description') - o.text = self.description - data.append(o) - - self.src.apply_tree(tree) - self.dst.apply_tree(tree) - - tree.append(data) - - for k in self.sigs.keys(): - o = etree.Element('sig', type='ed25519', keyid=k) - o.text = self.sigs[k].hex() - tree.append(o) - - return tree diff --git a/dummy/svcontas/ledger.py b/dummy/svcontas/ledger.py @@ -1,302 +0,0 @@ -import os -import datetime -import logging -import hashlib - -import lxml -import rencode - -from .crypto import DemoWallet -from .xml import nsmap, XML_FORMAT_VERSION -from .constant import NSPREFIX, DEFAULTPARENT -from .entry import Entry - -logg = logging.getLogger('svcontas.ledger') - - -class RunningTotal: - - def __init__(self, sym, unitindex, asset=0, liability=0, income=0, expense=0): - self.sym = sym - self.asset = asset - self.liability = liability - self.income = income - self.expense = expense - self.unitindex = unitindex - - - def get_balance(self): - return self.asset - self.liability - - - def get_result(self): - return self.income - self.expense - - - def income_delta(self, v): - self.income += v - - - def expense_delta(self, v): - self.expense += v - - - def asset_delta(self, v): - self.asset += v - - - def liability_delta(self, v): - self.liability += v - - - def apply_entry(self, entry): - src = entry.src.typ - dst = entry.dst.typ - src_isbalance = src in ['liability', 'asset'] - dst_isbalance = dst in ['liability', 'asset'] - - src_amount = entry.src.amount - dst_amount = entry.dst.amount - if src_isbalance and dst_isbalance: - if dst == 'liability': - src_amount *= -1 - dst_amount *= -1 - fn = getattr(self, entry.src.typ + '_delta') - fn(src_amount) - fn = getattr(self, entry.dst.typ + '_delta') - fn(dst_amount) - - logg.debug('applied entry {} src {} dst {} balance {}'.format(entry.serial, entry.src, entry.dst, self.unitindex.to_floatstring(self.sym, self.get_balance()))) - - - def __str__(self): - return 'running total {}: income {} expense {} asset {} liability {}'.format(self.sym, self.income, self.expense, self.asset, self.liability) - - -class Ledger: - - def __init__(self, unitindex, tree=None, acl=None, serial=0, base=None, topic=None): - self.uidx = unitindex - self.sigs = {} - self.entries = {} - self.running = {} - self.tree = tree - if self.tree == None: - self.reset() - self.serial = serial - if topic == None: - topic = os.urandom(64) - if base == None: - h = hashlib.sha512() - h.update(topic) - h.update(DEFAULTPARENT) - base = h.digest() - self.base = base - self.topic = topic - self.cur = base - self.acl = acl - logg.debug('ledger base {} from topic {}'.format(self.base.hex(), self.topic.hex())) - - - def peek(self): - return self.serial + 1 - - - def next_serial(self): - self.serial += 1 - return self.serial - - - def reset(self, src='defalsify.org', topic=None): - self.entries[self.uidx.base] = [] - self.running[self.uidx.base] = RunningTotal(self.uidx.base, self.uidx) - self.tree = lxml.etree.XML('<ledger xmlns="http://svcontas.defalsify.org/" version="{}"></ledger>'.format(XML_FORMAT_VERSION)) - #self.tree = lxml.etree.Element('ledger', nsmap=nsmap()) - o = lxml.etree.SubElement(self.tree, NSPREFIX + 'topic', nsmap=nsmap()) - if topic == None: - topic = os.urandom(64) - topic = topic.hex() - o.text = topic - o = lxml.etree.SubElement(self.tree, NSPREFIX + 'retrieved', nsmap=nsmap()) - o.text = datetime.datetime.strftime(datetime.datetime.now(), '%Y-%m-%dT%H:%M:%SZ') - #self.tree.append(o) - o = lxml.etree.SubElement(self.tree, NSPREFIX + 'src', nsmap=nsmap()) - o.text = src - - units = lxml.etree.SubElement(self.tree, NSPREFIX + 'units', nsmap=nsmap()) - units.attrib['base'] = self.uidx.base - for v in self.uidx.syms(): - unit = lxml.etree.SubElement(units, NSPREFIX + 'unit', nsmap=nsmap()) - unit.attrib['sym'] = v - o = lxml.etree.SubElement(unit, NSPREFIX + 'precision', nsmap=nsmap()) - o.text = str(self.uidx.get(v)) - #unit.append(o) - o = lxml.etree.SubElement(unit, NSPREFIX + 'exchange', nsmap=nsmap()) - o.text = str(self.uidx.ex(v)) - #unit.append(o) - #units.append(unit) - #self.tree.append(units) - - incoming = lxml.etree.SubElement(self.tree, NSPREFIX + 'incoming', nsmap=nsmap()) - incoming.attrib['serial'] = '0' - - real = lxml.etree.SubElement(incoming, NSPREFIX + 'real', nsmap=nsmap()) - real.attrib['unit'] = self.uidx.base - o = lxml.etree.SubElement(real, NSPREFIX + 'asset', nsmap=nsmap()) - o.text = '0' - #real.append(o) - o = lxml.etree.SubElement(real, NSPREFIX + 'liability', nsmap=nsmap()) - o.text = '0' - #real.append(o) - #incoming.append(real) - - o = lxml.etree.SubElement(incoming, NSPREFIX + 'digest', nsmap=nsmap()) - o.attrib['algo'] = 'sha512' - o.text = DEFAULTPARENT.hex() - #incoming.append(o) - #self.tree.append(incoming) - - - # TODO: should append after last - def add_identity(self, keyid, did, typ='web'): - root = self.tree - tree = root.find('resolver', namespaces=nsmap()) - if tree == None: - tree = root.find('units', namespaces=nsmap()) - if tree == None: - logg.debug('exception tree {}'.format(lxml.etree.tostring(self.tree))) - raise Exception('cannot find units node') - o = lxml.etree.Element(NSPREFIX + 'identity', nsmap=nsmap()) - o.attrib['keyid'] = keyid - o.attrib['didtype'] = typ - o.text = did - tree.addnext(o) - - - # TODO: should append after last - def add_resolver(self, uri, algo='sha256', proto='https'): - tree = self.tree.find('units', namespaces=nsmap()) - o = lxml.etree.Element(NSPREFIX + 'resolver', nsmap=nsmap()) - o.attrib['algo'] = algo - o.attrib['proto'] = proto - o.text = uri - tree.addnext(o) - - - # TODO: add check against trusted pubkey list - def check_sigs(self, entry): - have = False - valid_keys = None - if self.acl == None: - valid_keys = list(entry.sigs.keys()) - else: - valid_keys = list(self.acl.pubkeys(binary=False)) - logg.debug('testing valid keys {}'.format(valid_keys)) - for k in valid_keys: - b = bytes.fromhex(k) - try: - sig = entry.sigs[k] - except KeyError: - continue - wallet = DemoWallet(publickey=b) - v = entry.sum() - r = wallet.verify(v[0], sig) - have = True - logg.debug('having sig {}'.format(r.hex())) - return have - - - def add_entry(self, entry, modify_tree=True): - if not self.check_sigs(entry): - raise ValueError('entry must have at least one valid signature') - try: - entries = self.entries[entry.serial] - except KeyError: - self.entries[entry.serial] = [] - #entries = self.entries[entry.serial] - self.serial = entry.serial - oldbase = self.base - self.cur = entry.sum()[0] - entry.parent = oldbase - self.entries[entry.serial].append(entry) - self.running[entry.unit].apply_entry(entry) - if self.tree != None and modify_tree: - self.tree.append(entry.to_tree()) - logg.debug('entryunit {} {}'.format(entry.unit, self.running[entry.unit])) - - - def add_signature(self, sigdata, identity): - self.sigs[identity] = sigdata - logg.debug('add sig from key{}: {}'.format(identity, sigdata)) - - - @staticmethod - def from_tree(tree, unitindex, acl=None): - topic_node = tree.find('topic', namespaces=nsmap()) - topic = bytes.fromhex(topic_node.text) - - units = tree.find('units', namespaces=nsmap()) - unit = units.get('base') - part = tree.find('incoming', namespaces=nsmap()) - serial = int(part.get('serial')) - o = part.find('digest', namespaces=nsmap()).text # verify that is sha512 - r = Ledger(unitindex, topic=topic, tree=tree, acl=acl, serial=serial, base=bytes.fromhex(o)) - - for sig in part.iter(NSPREFIX + 'sig'): - keyid = sig.get('keyid') - digest = sig.text - r.add_signature(digest, keyid) - - o = part.find('real', namespaces=nsmap()) - asset = int(o.find('asset', namespaces=nsmap()).text) - liability = int(o.find('liability', namespaces=nsmap()).text) - r.real = RunningTotal(unit, unitindex, asset=asset, liability=liability) - - for v in part.iter(NSPREFIX + 'virt'): - income = int(v.find('income', namespaces=nsmap()).text) - expense = int(v.find('expense', namespaces=nsmap()).text) - asset = int(v.find('asset', namespaces=nsmap()).text) - liability = int(v.find('liability', namespaces=nsmap()).text) - sym = v.get('unit') - r.running[sym] = RunningTotal(sym, unitindex, income=income, expense=expense, asset=asset, liability=liability) - logg.debug(r.running[sym]) - - if r.running.get(unit) == None: - r.running[unit] = RunningTotal(unit, unitindex) - - r.apply_tree(tree) - logg.debug('loaded ledger tree last serial {}'.format(r.serial)) - return r.check() - - - def apply_tree(self, tree): - start = self.serial - last = 0 - for v in tree.iter(NSPREFIX + 'entry'): - logg.debug('processing entry {}'.format(v)) - o = Entry.from_tree(v, self.uidx, min=self.serial) - self.add_entry(o, modify_tree=False) - if o.serial > last: - last = o.serial - self.serial = last - logg.info('last entry from tree serial ' + str(self.serial)) - - - def to_tree(self): - return self.tree - - - def check(self): - return self - - - def to_string(self): - return lxml.etree.tostring(self.tree) - - - def current(self): - return self.cur - - - def __str__(self): - return "state: " + self.base.hex() diff --git a/dummy/svcontas/unit.py b/dummy/svcontas/unit.py @@ -1,94 +0,0 @@ -import logging - -from .constant import NSPREFIX -from .xml import nsmap - -logg = logging.getLogger('svcontas.unit') - -BASE_UNIT = 'BTC' - - -class UnitIndex: - - def __init__(self, base): - self.base = base - self.detail = {base: 0} - self.exchange = {base: 1} - - - def add(self, sym, precision=2, ex=1): - self.detail[sym] = precision - self.exchange[sym] = ex - - - @staticmethod - def from_tree(tree): - r = UnitIndex(tree.get('base')) - logg.debug('base {}'.format(tree)) - for o in tree.iter(NSPREFIX + 'unit'): - logg.debug('add unit ' + o.get('sym')) - r.detail[o.get('sym')] = int(o.find('precision', namespaces=nsmap()).text) - r.exchange[o.get('sym')] = int(o.find('ex', namespaces=nsmap()).text) - r.check() - return r - - - def check(self): - self.get(self.base) - return self - - - def get(self, k): - return self.detail[k] - - - def sym(self, k): - _ = self.get(k) - return k - - - def ex(self, k): - return self.exchange[k] - - - def syms(self): - return list(self.detail.keys()) - - - def to_floatstring(self, sym, v, allow_negative=True): - neg = v < 0 - if neg and not allow_negative: - raise ValueError('negative value not allowed') - v = abs(v) - c = self.detail[sym] - i = c * -1 - s = str(v) - l = len(s) - if l < c: - ss = '0' * c - s = '0' + ss[:c-l] + s - r = s[:i] + '.' + s[i:] - if neg: - r = '-' + r - return r - - - def from_floatstring(self, sym, v, allow_negative=True): - neg = False - if v[0] == '-': - if not allow_negative: - raise ValueError('negative value not allowed') - neg = True - v = v[1:] - c = self.detail[sym] - s = v.split('.') - if len(s) == 1: - return int(s[0]) * (10**c) - r = s[1] - l = len(r) - if l < c: - r += '0' * (c - l) - r = s[0] + r - if neg: - r *= -1 - return int(r) diff --git a/dummy/tests/entry.py b/dummy/tests/entry.py @@ -4,8 +4,8 @@ import unittest import os import copy -from svcontas import EntryPart, Entry, DemoWallet, ACL -from svcontas.error import ACLError +from usawa import EntryPart, Entry, DemoWallet, ACL +from usawa.error import ACLError logging.basicConfig(level=logging.DEBUG) logg = logging.getLogger() diff --git a/dummy/tests/ledger.py b/dummy/tests/ledger.py @@ -7,8 +7,8 @@ import copy import lxml.etree from whee.mem import MemStore -from svcontas import Ledger, UnitIndex, EntryPart, Entry, DemoWallet -from svcontas.store import LedgerStore +from usawa import Ledger, UnitIndex, EntryPart, Entry, DemoWallet +from usawa.store import LedgerStore logging.basicConfig(level=logging.DEBUG) logg = logging.getLogger() diff --git a/dummy/tests/store.py b/dummy/tests/store.py @@ -7,8 +7,8 @@ import copy import lxml.etree from whee.mem import MemStore -from svcontas import Ledger, UnitIndex, EntryPart, Entry, DemoWallet -from svcontas.store import LedgerStore +from usawa import Ledger, UnitIndex, EntryPart, Entry, DemoWallet +from usawa.store import LedgerStore logging.basicConfig(level=logging.DEBUG) logg = logging.getLogger() diff --git a/dummy/tests/test.xml b/dummy/tests/test.xml @@ -1,5 +1,5 @@ <?xml version='1.0' encoding='UTF-8' standalone='yes'?> -<ledger xmlns="http://svcontas.defalsify.org/"> +<ledger xmlns="http://usawa.defalsify.org/"> <topic>fca5eb40383af54fb270283251683f97f8804f35036264bb657e72f1d53b6bcf6afc532b02d2216dff01a2df69f5515c887833b98ed15a8bc6433fb28c5b4834</topic> <retrieved>2025-11-02T13:09:55Z</retrieved> <src>playalastunas.org</src> diff --git a/dummy/svcontas/__init__.py b/dummy/usawa/__init__.py diff --git a/dummy/usawa/cmd.py b/dummy/usawa/cmd.py @@ -0,0 +1,8 @@ +import enum +import rencode + +class Op(enum.IntEnum): + NOOP = 0 + UPDATE = 1 + + diff --git a/dummy/usawa/constant.py b/dummy/usawa/constant.py @@ -0,0 +1,4 @@ +DEFAULTPARENT = b'\x00' * 64 +NS = 'http://usawa.defalsify.org/' +NAMESPACES = {None: NS} +NSPREFIX = '{' + NS + '}' diff --git a/dummy/svcontas/crypto.py b/dummy/usawa/crypto.py diff --git a/dummy/usawa/entry.py b/dummy/usawa/entry.py @@ -0,0 +1,270 @@ +import enum +import logging +import datetime +import uuid +import hashlib + +from lxml import etree +import rencode + +from .constant import DEFAULTPARENT, NSPREFIX +from .crypto import DemoWallet +from .error import ACLError, VerifyError +from .xml import nsmap + +logg = logging.getLogger('usawa.entry') + + +class KeyStoreFormat(enum.IntEnum): + LITERAL = 0 + INDEXED = 1 + + +class EntryPart: + + def __init__(self, typ, account, amount, src=False): + self.typ = typ + self.account = account + self.amount = amount + self.issrc = src + + + @staticmethod + def from_tree(tree, src=False): + typ = tree.get('type') + amount = int(tree.find('amount', namespaces=nsmap()).text) + account = tree.find('account', namespaces=nsmap()).text + return EntryPart(typ, account, amount, src=src) + + + def apply_tree(self, tree): + tag = 'dst' + if self.issrc: + tag = 'src' + part = etree.Element(tag, type=self.typ) + o = etree.Element('account') + o.text = self.account + part.append(o) + + o = etree.Element('amount') + o.text = str(self.amount) + part.append(o) + + tree.append(part) + return part + + + def __str__(self): + pfx = 'dst' + if self.issrc: + pfx = 'src' + return '[{}] {}:{} {}'.format(pfx, self.typ, self.account, self.amount) + + +class Entry: + + digest_algo = 'sha512' + + # TODO: parent only 0 if serial 0 + def __init__(self, src, dst, unit, serial, tx_date, ref=None, description=None, parent=None, tx_datereg=None): + if isinstance(parent, str): + parent = bytes.fromhex(parent) + elif parent == None: + parent = DEFAULTPARENT + elif len(parent) != 64: + raise ValueError('invalid parent hash') + if ref == None: + ref = str(uuid.uuid4()) + self.ref = ref + self.parent = parent + self.unit = unit + self.serial = serial + self.dt = tx_date + if tx_datereg == None: + tx_datereg = datetime.datetime.now() + self.dtreg = tx_datereg + self.attachment = [] + self.sigs = {} + self.description = description + self.src = src + self.dst = dst + + + def attach(self, mime, algo, digest, description=None, slug=None): + self.attachment.append((mime, algo, digest, description, slug,)) + + + def add_signature(self, keyid, sigdata): + self.sigs[keyid] = sigdata + + + @staticmethod + def from_tree(tree, unitindex, min=0): + o = tree.find('data', namespaces=nsmap()) + serial = int(o.find('serial', namespaces=nsmap()).text) + if min > serial: + raise ValueError('entry serial preceeds ledger') + unit = o.find('unit', namespaces=nsmap()).text + unitindex.sym(unit) + + ref = o.find('ref', namespaces=nsmap()).text + parent = o.find('parent', namespaces=nsmap()).text + description = o.find('description', namespaces=nsmap()) + if description != None: + description = description.text + dt = datetime.date.fromisoformat(o.find('date', namespaces=nsmap()).text) + dtreg = datetime.datetime.strptime(o.find('dateTimeRegistered', namespaces=nsmap()).text, '%Y-%m-%dT%H:%M:%SZ') + src = EntryPart.from_tree(tree.find('src', namespaces=nsmap()), src=True) + dst = EntryPart.from_tree(tree.find('dst', namespaces=nsmap())) + + r = Entry(src, dst, unit, serial, dt, ref=ref, parent=parent, tx_datereg=dtreg, description=description) + for sig in tree.iter(NSPREFIX + 'sig'): + r.add_signature(sig.get('keyid'), bytes.fromhex(sig.text)) + return r + + + def serialize(self): + src = [self.src.typ, self.src.account, self.src.amount] + dst = [self.dst.typ, self.dst.account, self.dst.amount] + d = [ + self.parent, + self.serial, + self.ref, + self.dtreg.strftime('%Y%m%d%H%M%S'), + self.dt.strftime('%Y%m%d'), + self.unit, + self.description, + src, + dst, + ] + logg.debug('serialize entry {}'.format(d)) + return rencode.dumps(d) + + + @staticmethod + def deserialize(data): + v = rencode.loads(data) + parent = v[0] + serial = v[1] + ref = v[2] + date_reg = datetime.datetime.strptime(v[3].decode('utf-8'), '%Y%m%d%H%M%S') + date = datetime.datetime.strptime(v[4].decode('utf-8'), '%Y%m%d') + unit = v[5] + description = v[6] + src_data = v[7] + dst_data = v[8] + src = EntryPart(src_data[0], src_data[1], src_data[2], src=True) + dst = EntryPart(dst_data[0], dst_data[1], dst_data[2]) + return Entry(src, dst, unit, serial, date, ref=ref, description=description, parent=parent, tx_datereg=date_reg) + + + def sum(self): + b = self.serialize() + h = hashlib.new(self.digest_algo) + h.update(b) + return (h.digest(), b) + + + def sign(self, wallet): + (z, b) = self.sum() + r = wallet.sign(z) + pubk_hx = wallet.pubkey().hex() + self.sigs[pubk_hx] = r + logg.debug('added signature from key {}'.format(pubk_hx)) + return (z, r, b,) + + + def wrap(self, wallet=None): + digest = None + sig = None + data = None + if wallet != None: + (digest, sig, data) = self.sign(wallet) + hdr = [] + sigs = [] + for k in self.sigs: + hdr.append([ + KeyStoreFormat.LITERAL.value, + bytes.fromhex(k) + ]) + sigs.append(self.sigs[k]) + + if len(sigs) == 0: + raise VerifyError() + + d = [ + hdr, + sigs, + data, + ] + return rencode.dumps(d) + + + @staticmethod + def unwrap(data, acl=None): + v = rencode.loads(data) + # TODO: demo only takes into account single signature + pubkey_bytes = v[0][0][1] + if acl != None: + label = None + try: + label = acl.have(pubkey_bytes) + except KeyError: + raise ACLError() + if not acl.may(label, 0x01): + raise ACLError() + wallet = DemoWallet(publickey=pubkey_bytes) + # TODO: demo only takes into account single signature + sig = v[1][0] + entry = Entry.deserialize(v[2]) + (z, b) = entry.sum() + wallet.verify(z, sig) + return entry + + + + def to_tree(self): + #tree = etree.Element('entry', type=self.typ) + tree = etree.Element('entry') + data = etree.Element('data') + + o = etree.Element('parent') + o.text = self.parent.hex() + data.append(o) + + o = etree.Element('ref') + o.text = self.ref + data.append(o) + + o = etree.Element('serial') + o.text = str(self.serial) + data.append(o) + + o = etree.Element('unit') + o.text = self.unit + data.append(o) + + o = etree.Element('date') + o.text = self.dt.strftime('%Y-%m-%d') + data.append(o) + + o = etree.Element('dateTimeRegistered') + o.text = self.dtreg.strftime('%Y-%m-%dT%H:%M:%SZ') + data.append(o) + + if self.description: + o = etree.Element('description') + o.text = self.description + data.append(o) + + self.src.apply_tree(tree) + self.dst.apply_tree(tree) + + tree.append(data) + + for k in self.sigs.keys(): + o = etree.Element('sig', type='ed25519', keyid=k) + o.text = self.sigs[k].hex() + tree.append(o) + + return tree diff --git a/dummy/svcontas/error.py b/dummy/usawa/error.py diff --git a/dummy/usawa/ledger.py b/dummy/usawa/ledger.py @@ -0,0 +1,413 @@ +import os +import datetime +import logging +import hashlib + +import lxml +import rencode + +from .crypto import DemoWallet +from .xml import nsmap, XML_FORMAT_VERSION +from .constant import NSPREFIX, DEFAULTPARENT +from .entry import Entry + +logg = logging.getLogger('usawa.ledger') + + +class RunningTotal: + + def __init__(self, sym, unitindex, asset=0, liability=0, income=0, expense=0): + self.sym = sym + self.asset = asset + self.liability = liability + self.income = income + self.expense = expense + self.unitindex = unitindex + + + def get_balance(self): + return self.asset - self.liability + + + def get_result(self): + return self.income - self.expense + + + def income_delta(self, v): + self.income += v + + + def expense_delta(self, v): + self.expense += v + + + def asset_delta(self, v): + self.asset += v + + + def liability_delta(self, v): + self.liability += v + + + def apply_entry(self, entry): + src = entry.src.typ + dst = entry.dst.typ + src_isbalance = src in ['liability', 'asset'] + dst_isbalance = dst in ['liability', 'asset'] + + src_amount = entry.src.amount + dst_amount = entry.dst.amount + if src_isbalance and dst_isbalance: + if dst == 'liability': + src_amount *= -1 + dst_amount *= -1 + fn = getattr(self, entry.src.typ + '_delta') + fn(src_amount) + fn = getattr(self, entry.dst.typ + '_delta') + fn(dst_amount) + + logg.debug('applied entry {} src {} dst {} balance {}'.format(entry.serial, entry.src, entry.dst, self.unitindex.to_floatstring(self.sym, self.get_balance()))) + + + def __str__(self): + return 'running total {}: income {} expense {} asset {} liability {}'.format(self.sym, self.income, self.expense, self.asset, self.liability) + + +class Ledger: + """Ledger represents a signed and verified chain of transaction entries. + + Apart from the entries chain, it also holds metadata required to expand underlying assets referenced by the entries, aswell as resolution and verification of identities of signatories. + + If populated by an XML tree, all unit definitions encountered will be added to the provided UnitIndex. All other parameters will be ignored. + + If the serial number parameter is non-zero, the base must be a non-zero digest value. + + :param unitindex: The unit index to resolve transaction values used in the entries. + :type unitindex: usawa.UnitIndex + :param tree: A verified XML tree to use to populate the ledger. + :type tree: lxml.etree.ElementTree + :param acl: A collection of public keys to use to verify signatures. Will override existing public key lists. + :type acl: usawa.ACL + :param serial: Serial number to start the current state of the ledger on. + :type serial: int + :param base: Hexadecimal digest value defining the state of the ledger at the corresponding serial number. The digest type is defined in usawa.Entry.digest_algo + :type base: str + :param topic: Hexadecimal userdata providing context of the ledger. + :type topic: str + :todo: Add warnings for ignored parameters + """ + + def __init__(self, unitindex, tree=None, acl=None, serial=0, base=None, topic=None): + self.uidx = unitindex + self.sigs = {} + self.entries = {} + self.running = {} + self.tree = tree + if self.tree == None: + self.reset() + self.serial = serial + if topic == None: + topic = os.urandom(64) + if base == None: + h = hashlib.sha512() + h.update(topic) + h.update(DEFAULTPARENT) + base = h.digest() + self.base = base + self.topic = topic + self.cur = base + self.acl = acl + logg.debug('ledger base {} from topic {}'.format(self.base.hex(), self.topic.hex())) + + + """Retrieve the serial that will be assigned to the next entry. + + :rtype: int + :return: Serial + """ + def peek(self): + return self.serial + 1 + + """Increment the serial and return the incremented serial, to be used for the next entry. + + :rtype: int + :return: Serial + """ + def next_serial(self): + self.serial += 1 + return self.serial + + + """Remove all entries from the ledger, and reset all metadata to defaults. + + :param src: URI to the source of ledger information. + :type src: str + :rtype: None + """ + def reset(self, src='defalsify.org', topic=None): + self.entries[self.uidx.base] = [] + self.running[self.uidx.base] = RunningTotal(self.uidx.base, self.uidx) + self.tree = lxml.etree.XML('<ledger xmlns="http://usawa.defalsify.org/" version="{}"></ledger>'.format(XML_FORMAT_VERSION)) + #self.tree = lxml.etree.Element('ledger', nsmap=nsmap()) + o = lxml.etree.SubElement(self.tree, NSPREFIX + 'topic', nsmap=nsmap()) + if topic == None: + topic = os.urandom(64) + topic = topic.hex() + o.text = topic + o = lxml.etree.SubElement(self.tree, NSPREFIX + 'retrieved', nsmap=nsmap()) + o.text = datetime.datetime.strftime(datetime.datetime.now(), '%Y-%m-%dT%H:%M:%SZ') + #self.tree.append(o) + o = lxml.etree.SubElement(self.tree, NSPREFIX + 'src', nsmap=nsmap()) + o.text = src + + units = lxml.etree.SubElement(self.tree, NSPREFIX + 'units', nsmap=nsmap()) + units.attrib['base'] = self.uidx.base + for v in self.uidx.syms(): + unit = lxml.etree.SubElement(units, NSPREFIX + 'unit', nsmap=nsmap()) + unit.attrib['sym'] = v + o = lxml.etree.SubElement(unit, NSPREFIX + 'precision', nsmap=nsmap()) + o.text = str(self.uidx.get(v)) + #unit.append(o) + o = lxml.etree.SubElement(unit, NSPREFIX + 'exchange', nsmap=nsmap()) + o.text = str(self.uidx.ex(v)) + #unit.append(o) + #units.append(unit) + #self.tree.append(units) + + incoming = lxml.etree.SubElement(self.tree, NSPREFIX + 'incoming', nsmap=nsmap()) + incoming.attrib['serial'] = '0' + + real = lxml.etree.SubElement(incoming, NSPREFIX + 'real', nsmap=nsmap()) + real.attrib['unit'] = self.uidx.base + o = lxml.etree.SubElement(real, NSPREFIX + 'asset', nsmap=nsmap()) + o.text = '0' + #real.append(o) + o = lxml.etree.SubElement(real, NSPREFIX + 'liability', nsmap=nsmap()) + o.text = '0' + #real.append(o) + #incoming.append(real) + + o = lxml.etree.SubElement(incoming, NSPREFIX + 'digest', nsmap=nsmap()) + o.attrib['algo'] = 'sha512' + o.text = DEFAULTPARENT.hex() + #incoming.append(o) + #self.tree.append(incoming) + + + """Add a decentralized identity definition for signers of the ledger. + + :param keyid: Hexadecimal representation of the key identifier. + :type keyid: str + :param did: Did provider. + :type did: str + :param typ: Type of did provider, default 'web'. + :type did: str + :todo: should append after last + """ + def add_identity(self, keyid, did, typ='web'): + root = self.tree + tree = root.find('resolver', namespaces=nsmap()) + if tree == None: + tree = root.find('units', namespaces=nsmap()) + if tree == None: + logg.debug('exception tree {}'.format(lxml.etree.tostring(self.tree))) + raise Exception('cannot find units node') + o = lxml.etree.Element(NSPREFIX + 'identity', nsmap=nsmap()) + o.attrib['keyid'] = keyid + o.attrib['didtype'] = typ + o.text = did + tree.addnext(o) + + + """Add an endpoint to resolve content by digest. + + :param location: The endpoint location, host and path or only path, depending on the scheme. + :type path: str + :param algo: Digest algorithm to use to retrieve and verify content, default 'sha256'. + :type path: str + :param proto: URI scheme used to connect to the endpoint. + :param algo: str + :todo: should append after last + """ + def add_resolver(self, location, algo='sha256', scheme='https'): + tree = self.tree.find('units', namespaces=nsmap()) + o = lxml.etree.Element(NSPREFIX + 'resolver', nsmap=nsmap()) + o.attrib['algo'] = algo + o.attrib['proto'] = proto + o.text = path + tree.addnext(o) + + """Check signature on individual ledger entry. + + :param entry: The individual entry to verify. + :type entry: usawa.Entry + """ + def check_sigs(self, entry): + have = False + valid_keys = None + if self.acl == None: + valid_keys = list(entry.sigs.keys()) + else: + valid_keys = list(self.acl.pubkeys(binary=False)) + logg.debug('testing valid keys {}'.format(valid_keys)) + for k in valid_keys: + b = bytes.fromhex(k) + try: + sig = entry.sigs[k] + except KeyError: + continue + wallet = DemoWallet(publickey=b) + v = entry.sum() + r = wallet.verify(v[0], sig) + have = True + logg.debug('having sig {}'.format(r.hex())) + return have + + + """Append entry to ledger. The entry must have a valid signature from a trusted public key. + + :param entry: The entry to append. + :type entry: usawa.Entry + :param modify_tree: If True, also append the entry to the XML export. + :type modify_tree: boolean + :todo: modify_tree is too low-level for this API + """ + def add_entry(self, entry, modify_tree=True): + if not self.check_sigs(entry): + raise ValueError('entry must have at least one valid signature') + try: + entries = self.entries[entry.serial] + except KeyError: + self.entries[entry.serial] = [] + #entries = self.entries[entry.serial] + self.serial = entry.serial + oldbase = self.base + self.cur = entry.sum()[0] + entry.parent = oldbase + self.entries[entry.serial].append(entry) + self.running[entry.unit].apply_entry(entry) + if self.tree != None and modify_tree: + self.tree.append(entry.to_tree()) + logg.debug('entryunit {} {}'.format(entry.unit, self.running[entry.unit])) + + + """Add a signature on the ledger. + + :todo: not an appropriate API function? + :todo: implement validity checks for signature. + """ + def add_signature(self, sigdata, identity): + self.sigs[identity] = sigdata + logg.debug('add sig from key{}: {}'.format(identity, sigdata)) + + + """Create a new ledger from a parsed XML document. + + Does not check validity of tree against schema. + + :param tree: A parsed and validated XML tree. + :type tree: lxml.etree.ElementTree + :param unitindex: Definition of all units used in the XML tree. + :type unitindex: usawa.UnitIndex + :param acl: List of public keys to validate signatures against. Overrides the keys in the entry object. + :type acl: usawa.ACL + :todo: Specify in docs which exception raised if unit not found in index. + """ + @staticmethod + def from_tree(tree, unitindex, acl=None): + topic_node = tree.find('topic', namespaces=nsmap()) + topic = bytes.fromhex(topic_node.text) + + units = tree.find('units', namespaces=nsmap()) + unit = units.get('base') + part = tree.find('incoming', namespaces=nsmap()) + serial = int(part.get('serial')) + o = part.find('digest', namespaces=nsmap()).text # verify that is sha512 + r = Ledger(unitindex, topic=topic, tree=tree, acl=acl, serial=serial, base=bytes.fromhex(o)) + + for sig in part.iter(NSPREFIX + 'sig'): + keyid = sig.get('keyid') + digest = sig.text + r.add_signature(digest, keyid) + + o = part.find('real', namespaces=nsmap()) + asset = int(o.find('asset', namespaces=nsmap()).text) + liability = int(o.find('liability', namespaces=nsmap()).text) + r.real = RunningTotal(unit, unitindex, asset=asset, liability=liability) + + for v in part.iter(NSPREFIX + 'virt'): + income = int(v.find('income', namespaces=nsmap()).text) + expense = int(v.find('expense', namespaces=nsmap()).text) + asset = int(v.find('asset', namespaces=nsmap()).text) + liability = int(v.find('liability', namespaces=nsmap()).text) + sym = v.get('unit') + r.running[sym] = RunningTotal(sym, unitindex, income=income, expense=expense, asset=asset, liability=liability) + logg.debug(r.running[sym]) + + if r.running.get(unit) == None: + r.running[unit] = RunningTotal(unit, unitindex) + + r.apply_tree(tree) + logg.debug('loaded ledger tree last serial {}'.format(r.serial)) + return r.check() + + + """Append all entries from XML tree to ledger. + + :param tree: A parsed XML tree. + :todo: Not an API method. + """ + def apply_tree(self, tree): + start = self.serial + last = 0 + for v in tree.iter(NSPREFIX + 'entry'): + logg.debug('processing entry {}'.format(v)) + o = Entry.from_tree(v, self.uidx, min=self.serial) + self.add_entry(o, modify_tree=False) + if o.serial > last: + last = o.serial + self.serial = last + logg.info('last entry from tree serial ' + str(self.serial)) + + + """Return XML tree representation of the state of the ledger object. + + :returns: XML tree. + :rtype: lxml.etree.ElementTree + """ + def to_tree(self): + return self.tree + + + """Verify digest chain and signatures in ledger. + + :todo: implement, currently a no-op + """ + def check(self): + return self + + + """Return a string representation of the XML tree. + + :returns: XML document in UTF-8 format. + :rtype: str + """ + def to_string(self): + return lxml.etree.tostring(self.tree) + + + """Returns the digest of the current state of the ledger. + + The digest is calculated on the full chain of entries currently in the ledger. + + The digest type is defined in the usawa.Entry.digest_algo. + + :returns: Digest. + :rtype: bytes + """ + def current(self): + return self.cur + + + def __str__(self): + return "state: " + self.base.hex() diff --git a/dummy/usawa/runnable/server.py b/dummy/usawa/runnable/server.py @@ -0,0 +1,35 @@ +import socket +import logging + +logging.basicConfig(level=logging.DEBUG) +logg = logging.getLogger() + + +def parse(v): + logg.debug('parsing {}'.format(v.hex())) + +def main(): + scks = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + scks.bind(('', 32327,)) + scks.listen(1) + while True: + logg.debug('waiting for connection') + (sckc, address) = scks.accept() + logg.info('connect: {}'.format(address)) + c = 0 + data = bytearray() + while True: + b = sckc.recv(2048) + if len(b) == 0: + logg.info('connection broken: {}'.format(address)) + break + for v in b: + if v == 0x0a: + logg.debug('command boundary reached') + parse(bytes(data)) + data.append(v) + logg.debug('read {}: {}'.format(len(b), b.hex())) + + +if __name__ == '__main__': + main() diff --git a/dummy/svcontas/store.py b/dummy/usawa/store.py diff --git a/dummy/usawa/unit.py b/dummy/usawa/unit.py @@ -0,0 +1,94 @@ +import logging + +from .constant import NSPREFIX +from .xml import nsmap + +logg = logging.getLogger('usawa.unit') + +BASE_UNIT = 'BTC' + + +class UnitIndex: + + def __init__(self, base): + self.base = base + self.detail = {base: 0} + self.exchange = {base: 1} + + + def add(self, sym, precision=2, ex=1): + self.detail[sym] = precision + self.exchange[sym] = ex + + + @staticmethod + def from_tree(tree): + r = UnitIndex(tree.get('base')) + logg.debug('base {}'.format(tree)) + for o in tree.iter(NSPREFIX + 'unit'): + logg.debug('add unit ' + o.get('sym')) + r.detail[o.get('sym')] = int(o.find('precision', namespaces=nsmap()).text) + r.exchange[o.get('sym')] = int(o.find('ex', namespaces=nsmap()).text) + r.check() + return r + + + def check(self): + self.get(self.base) + return self + + + def get(self, k): + return self.detail[k] + + + def sym(self, k): + _ = self.get(k) + return k + + + def ex(self, k): + return self.exchange[k] + + + def syms(self): + return list(self.detail.keys()) + + + def to_floatstring(self, sym, v, allow_negative=True): + neg = v < 0 + if neg and not allow_negative: + raise ValueError('negative value not allowed') + v = abs(v) + c = self.detail[sym] + i = c * -1 + s = str(v) + l = len(s) + if l < c: + ss = '0' * c + s = '0' + ss[:c-l] + s + r = s[:i] + '.' + s[i:] + if neg: + r = '-' + r + return r + + + def from_floatstring(self, sym, v, allow_negative=True): + neg = False + if v[0] == '-': + if not allow_negative: + raise ValueError('negative value not allowed') + neg = True + v = v[1:] + c = self.detail[sym] + s = v.split('.') + if len(s) == 1: + return int(s[0]) * (10**c) + r = s[1] + l = len(r) + if l < c: + r += '0' * (c - l) + r = s[0] + r + if neg: + r *= -1 + return int(r) diff --git a/dummy/svcontas/xml.py b/dummy/usawa/xml.py