From 13a1e47a3d93fb47a19f4718ee588fcef8322f4c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 11 Sep 2022 04:31:37 +0300 Subject: [PATCH] initial commit --- cashu | 71 ++++++++++++++++ core/__init__.py | 0 core/b_dhke.py | 91 ++++++++++++++++++++ core/db.py | 192 +++++++++++++++++++++++++++++++++++++++++++ core/helpers.py | 26 ++++++ core/settings.py | 1 + core/split.py | 8 ++ mint/__init__.py | 0 mint/app.py | 65 +++++++++++++++ mint/crud.py | 51 ++++++++++++ mint/ledger.py | 134 ++++++++++++++++++++++++++++++ mint/migrations.py | 67 +++++++++++++++ test_wallet.py | 95 +++++++++++++++++++++ wallet/__init__.py | 3 + wallet/crud.py | 69 ++++++++++++++++ wallet/migrations.py | 55 +++++++++++++ wallet/models.py | 20 +++++ wallet/wallet.py | 180 ++++++++++++++++++++++++++++++++++++++++ 18 files changed, 1128 insertions(+) create mode 100755 cashu create mode 100644 core/__init__.py create mode 100644 core/b_dhke.py create mode 100644 core/db.py create mode 100644 core/helpers.py create mode 100644 core/settings.py create mode 100644 core/split.py create mode 100644 mint/__init__.py create mode 100644 mint/app.py create mode 100644 mint/crud.py create mode 100644 mint/ledger.py create mode 100644 mint/migrations.py create mode 100644 test_wallet.py create mode 100644 wallet/__init__.py create mode 100644 wallet/crud.py create mode 100644 wallet/migrations.py create mode 100644 wallet/models.py create mode 100644 wallet/wallet.py diff --git a/cashu b/cashu new file mode 100755 index 0000000..98b751f --- /dev/null +++ b/cashu @@ -0,0 +1,71 @@ +#!/usr/bin/env python + +from wallet.wallet import Wallet as Wallet +from wallet.migrations import m001_initial +import asyncio +import click +import json +import base64 +from bech32 import bech32_encode, bech32_decode, convertbits + +SERVER_ENDPOINT = "http://localhost:5000" + + +import asyncio +from functools import wraps + + +# https://github.com/pallets/click/issues/85#issuecomment-503464628 +def coro(f): + @wraps(f) + def wrapper(*args, **kwargs): + return asyncio.run(f(*args, **kwargs)) + + return wrapper + + +@click.command("mint") +@click.option("--wallet", default="wallet", help="Mint tokens.") +@click.option("--mint", default=0, help="Mint tokens.") +@click.option("--send", default=0, help="Send tokens.") +@click.option("--receive", default="", help="Receive tokens.") +@click.option("--invalidate", default="", help="Invalidate tokens.") +@coro +async def main(wallet, mint, send, receive, invalidate): + wallet = Wallet(SERVER_ENDPOINT, f"data/{wallet}") + await m001_initial(db=wallet.db) + await wallet.load_proofs() + if mint: + print(f"Balance: {wallet.balance}") + await wallet.mint(mint) + print(f"Balance: {wallet.balance}") + + if send: + wallet.status() + _, send_proofs = await wallet.split(wallet.proofs, send) + print(base64.urlsafe_b64encode(json.dumps(send_proofs).encode()).decode()) + + if receive: + wallet.status() + proofs = json.loads(base64.urlsafe_b64decode(receive)) + _, _ = await wallet.redeem(proofs) + wallet.status() + + if invalidate: + wallet.status() + proofs = json.loads(base64.urlsafe_b64decode(invalidate)) + await wallet.invalidate(proofs) + wallet.status() + + +if __name__ == "__main__": + main() + + +@click.command("send") +@click.option("--send", default=1, help="Mint tokens.") +@coro +async def send(send): + print("asd") + # w1_fst_proofs, w1_snd_proofs = await wallet.split(proofs, 20) + return "asd" diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/b_dhke.py b/core/b_dhke.py new file mode 100644 index 0000000..87caa4c --- /dev/null +++ b/core/b_dhke.py @@ -0,0 +1,91 @@ +""" +Implementation of https://gist.github.com/RubenSomsen/be7a4760dd4596d06963d67baf140406 + +Alice: +A = a*G +return A + +Bob: +Y = hash_to_curve(secret_message) +r = random blinding factor +B'= Y + r*G +return B' + +Alice: +C' = a*B' + (= a*Y + a*r*G) +return C' + +Bob: +C = C' - r*A + (= C' - a*r*G) + (= a*Y) +return C, secret_message + +Alice: +Y = hash_to_curve(secret_message) +C == a*Y + +If true, C must have originated from Alice +""" + +import hashlib +from ecc.curve import secp256k1, Point +from ecc.key import gen_keypair + + +G = secp256k1.G + + +def hash_to_curve(secret_msg): + """Generates x coordinate from the message hash and checks if the point lies on the curve. + If it does not, it tries computing again a new x coordinate from the hash of the coordinate.""" + point = None + msg = secret_msg + while point is None: + x_coord = int(hashlib.sha256(msg).hexdigest().encode("utf-8"), 16) + y_coord = secp256k1.compute_y(x_coord) + try: + # Fails if the point is not on the curve + point = Point(x_coord, y_coord, secp256k1) + except: + msg = str(x_coord).encode("utf-8") + + return point + + +def step1_bob(secret_msg): + secret_msg = secret_msg.encode("utf-8") + Y = hash_to_curve(secret_msg) + r, _ = gen_keypair(secp256k1) + B_ = Y + r * G + return B_, r + + +def step2_alice(B_, a): + C_ = a * B_ + return C_ + + +def step3_bob(C_, r, A): + C = C_ - r * A + return C + + +def verify(a, C, secret_msg): + Y = hash_to_curve(secret_msg.encode("utf-8")) + return C == a * Y + + +### Below is a test of a simple positive and negative case + +# # Alice private key +# a, A = gen_keypair(secp256k1) +# secret_msg = "test" +# B_, r = step1_bob(secret_msg) +# C_ = step2_alice(B_, a) +# C = step3_bob(C_, r, A) +# print("C:{}, secret_msg:{}".format(C, secret_msg)) + +# assert verify(a, C, secret_msg) +# assert verify(a, C + 1*G, secret_msg) == False # adding 1*G shouldn't pass diff --git a/core/db.py b/core/db.py new file mode 100644 index 0000000..cf4baa6 --- /dev/null +++ b/core/db.py @@ -0,0 +1,192 @@ +import asyncio +import datetime +import os +import time +from contextlib import asynccontextmanager +from typing import Optional + +from sqlalchemy import create_engine +from sqlalchemy_aio.base import AsyncConnection +from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY # type: ignore + + +POSTGRES = "POSTGRES" +COCKROACH = "COCKROACH" +SQLITE = "SQLITE" + + +class Compat: + type: Optional[str] = "" + schema: Optional[str] = "" + + def interval_seconds(self, seconds: int) -> str: + if self.type in {POSTGRES, COCKROACH}: + return f"interval '{seconds} seconds'" + elif self.type == SQLITE: + return f"{seconds}" + return "" + + @property + def timestamp_now(self) -> str: + if self.type in {POSTGRES, COCKROACH}: + return "now()" + elif self.type == SQLITE: + return "(strftime('%s', 'now'))" + return "" + + @property + def serial_primary_key(self) -> str: + if self.type in {POSTGRES, COCKROACH}: + return "SERIAL PRIMARY KEY" + elif self.type == SQLITE: + return "INTEGER PRIMARY KEY AUTOINCREMENT" + return "" + + @property + def references_schema(self) -> str: + if self.type in {POSTGRES, COCKROACH}: + return f"{self.schema}." + elif self.type == SQLITE: + return "" + return "" + + +class Connection(Compat): + def __init__(self, conn: AsyncConnection, txn, typ, name, schema): + self.conn = conn + self.txn = txn + self.type = typ + self.name = name + self.schema = schema + + def rewrite_query(self, query) -> str: + if self.type in {POSTGRES, COCKROACH}: + query = query.replace("%", "%%") + query = query.replace("?", "%s") + return query + + async def fetchall(self, query: str, values: tuple = ()) -> list: + result = await self.conn.execute(self.rewrite_query(query), values) + return await result.fetchall() + + async def fetchone(self, query: str, values: tuple = ()): + result = await self.conn.execute(self.rewrite_query(query), values) + row = await result.fetchone() + await result.close() + return row + + async def execute(self, query: str, values: tuple = ()): + return await self.conn.execute(self.rewrite_query(query), values) + + +class Database(Compat): + def __init__(self, db_name: str, db_location: str): + self.name = db_name + self.db_location = db_location + self.db_location_is_url = "://" in self.db_location + + if self.db_location_is_url: + database_uri = self.db_location + + if database_uri.startswith("cockroachdb://"): + self.type = COCKROACH + else: + self.type = POSTGRES + + import psycopg2 # type: ignore + + def _parse_timestamp(value, _): + f = "%Y-%m-%d %H:%M:%S.%f" + if not "." in value: + f = "%Y-%m-%d %H:%M:%S" + return time.mktime(datetime.datetime.strptime(value, f).timetuple()) + + psycopg2.extensions.register_type( + psycopg2.extensions.new_type( + psycopg2.extensions.DECIMAL.values, + "DEC2FLOAT", + lambda value, curs: float(value) if value is not None else None, + ) + ) + psycopg2.extensions.register_type( + psycopg2.extensions.new_type( + (1082, 1083, 1266), + "DATE2INT", + lambda value, curs: time.mktime(value.timetuple()) + if value is not None + else None, + ) + ) + + psycopg2.extensions.register_type( + psycopg2.extensions.new_type( + (1184, 1114), + "TIMESTAMP2INT", + _parse_timestamp + # lambda value, curs: time.mktime( + # datetime.datetime.strptime( + # value, "%Y-%m-%d %H:%M:%S.%f" + # ).timetuple() + # ), + ) + ) + else: + if os.path.isdir(self.db_location): + self.path = os.path.join(self.db_location, f"{self.name}.sqlite3") + database_uri = f"sqlite:///{self.path}" + self.type = SQLITE + else: + raise NotADirectoryError( + f"db_location named {self.db_location} was not created" + f" - please 'mkdir {self.db_location}' and try again" + ) + self.schema = self.name + if self.name.startswith("ext_"): + self.schema = self.name[4:] + else: + self.schema = None + + self.engine = create_engine(database_uri, strategy=ASYNCIO_STRATEGY) + self.lock = asyncio.Lock() + + @asynccontextmanager + async def connect(self): + await self.lock.acquire() + try: + async with self.engine.connect() as conn: + async with conn.begin() as txn: + wconn = Connection(conn, txn, self.type, self.name, self.schema) + + if self.schema: + if self.type in {POSTGRES, COCKROACH}: + await wconn.execute( + f"CREATE SCHEMA IF NOT EXISTS {self.schema}" + ) + elif self.type == SQLITE: + await wconn.execute( + f"ATTACH '{self.path}' AS {self.schema}" + ) + + yield wconn + finally: + self.lock.release() + + async def fetchall(self, query: str, values: tuple = ()) -> list: + async with self.connect() as conn: + result = await conn.execute(query, values) + return await result.fetchall() + + async def fetchone(self, query: str, values: tuple = ()): + async with self.connect() as conn: + result = await conn.execute(query, values) + row = await result.fetchone() + await result.close() + return row + + async def execute(self, query: str, values: tuple = ()): + async with self.connect() as conn: + return await conn.execute(query, values) + + @asynccontextmanager + async def reuse_conn(self, conn: Connection): + yield conn diff --git a/core/helpers.py b/core/helpers.py new file mode 100644 index 0000000..5969d7b --- /dev/null +++ b/core/helpers.py @@ -0,0 +1,26 @@ +import asyncio +from functools import partial, wraps + + +def async_wrap(func): + @wraps(func) + async def run(*args, loop=None, executor=None, **kwargs): + if loop is None: + loop = asyncio.get_event_loop() + partial_func = partial(func, *args, **kwargs) + return await loop.run_in_executor(executor, partial_func) + + return run + + +def async_unwrap(to_await): + async_response = [] + + async def run_and_capture_result(): + r = await to_await + async_response.append(r) + + loop = asyncio.get_event_loop() + coroutine = run_and_capture_result() + loop.run_until_complete(coroutine) + return async_response[0] diff --git a/core/settings.py b/core/settings.py new file mode 100644 index 0000000..e90ebc2 --- /dev/null +++ b/core/settings.py @@ -0,0 +1 @@ +MAX_ORDER = 60 diff --git a/core/split.py b/core/split.py new file mode 100644 index 0000000..44b9cf5 --- /dev/null +++ b/core/split.py @@ -0,0 +1,8 @@ +def amount_split(amount): + """Given an amount returns a list of amounts returned e.g. 13 is [1, 4, 8].""" + bits_amt = bin(amount)[::-1][:-2] + rv = [] + for (pos, bit) in enumerate(bits_amt): + if bit == "1": + rv.append(2**pos) + return rv diff --git a/mint/__init__.py b/mint/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mint/app.py b/mint/app.py new file mode 100644 index 0000000..582a43c --- /dev/null +++ b/mint/app.py @@ -0,0 +1,65 @@ +import hashlib + +from ecc.curve import secp256k1, Point +from flask import Flask, request +import os +import asyncio + +from mint.ledger import Ledger +from mint.migrations import m001_initial + +# Ledger pubkey +ledger = Ledger("supersecretprivatekey", "../data/mint") + + +class MyFlaskApp(Flask): + """ + We overload the Flask class so we can run a startup script (migration). + Stupid Flask. + """ + + def __init__(self, *args, **kwargs): + async def create_tasks_func(): + await asyncio.wait([m001_initial(ledger.db)]) + + loop = asyncio.get_event_loop() + loop.run_until_complete(create_tasks_func()) + loop.close() + + return super().__init__(*args, **kwargs) + + def run(self, *args, **options): + super(MyFlaskApp, self).run(*args, **options) + + +app = MyFlaskApp(__name__) + + +@app.route("/keys") +def keys(): + return ledger.get_pubkeys() + + +@app.route("/mint", methods=["POST"]) +async def mint(): + amount = int(request.args.get("amount")) or 64 + x = int(request.json["x"]) + y = int(request.json["y"]) + B_ = Point(x, y, secp256k1) + try: + promise = await ledger.mint(B_, amount) + return promise + except Exception as exc: + return {"error": str(exc)} + + +@app.route("/split", methods=["POST"]) +async def split(): + proofs = request.json["proofs"] + amount = request.json["amount"] + output_data = request.json["output_data"] + try: + fst_promises, snd_promises = await ledger.split(proofs, amount, output_data) + return {"fst": fst_promises, "snd": snd_promises} + except Exception as exc: + return {"error": str(exc)} diff --git a/mint/crud.py b/mint/crud.py new file mode 100644 index 0000000..c29e3a7 --- /dev/null +++ b/mint/crud.py @@ -0,0 +1,51 @@ +import secrets +from typing import Optional +from core.db import Connection, Database + + +async def store_promise( + amount: int, + B_x: str, + B_y: str, + C_x: str, + C_y: str, + db: Database, + conn: Optional[Connection] = None, +): + + await (conn or db).execute( + """ + INSERT INTO promises + (amount, B_x, B_y, C_x, C_y) + VALUES (?, ?, ?, ?, ?) + """, + ( + amount, + str(B_x), + str(B_y), + str(C_x), + str(C_y), + ), + ) + + +async def invalidate_proof( + proof: dict, + db: Database, + conn: Optional[Connection] = None, +): + + # we add the proof and secret to the used list + await (conn or db).execute( + """ + INSERT INTO proofs_used + (amount, C_x, C_y, secret) + VALUES (?, ?, ?, ?) + """, + ( + proof["amount"], + str(proof["C"]["x"]), + str(proof["C"]["y"]), + str(proof["secret"]), + ), + ) diff --git a/mint/ledger.py b/mint/ledger.py new file mode 100644 index 0000000..3a6213b --- /dev/null +++ b/mint/ledger.py @@ -0,0 +1,134 @@ +""" +Implementation of https://gist.github.com/phyro/935badc682057f418842c72961cf096c +""" + +import hashlib + +from ecc.curve import secp256k1, Point +from ecc.key import gen_keypair + +import core.b_dhke as b_dhke +from core.db import Database +from core.split import amount_split +from core.settings import MAX_ORDER +from mint.crud import store_promise, invalidate_proof + + +class Ledger: + def __init__(self, secret_key: str, db: str): + self.master_key = secret_key + self.proofs_used = set() # no promise proofs have been used + self.keys = self._derive_keys(self.master_key) + self.db = Database("mint", db) + + @staticmethod + def _derive_keys(master_key): + """Deterministic derivation of keys for 2^n values.""" + return { + 2 + ** i: int( + hashlib.sha256((str(master_key) + str(i)).encode("utf-8")) + .hexdigest() + .encode("utf-8"), + 16, + ) + for i in range(MAX_ORDER) + } + + async def _generate_promises(self, amounts, B_s): + """Generates promises that sum to the given amount.""" + return [ + await self._generate_promise(amount, Point(B_["x"], B_["y"], secp256k1)) + for (amount, B_) in zip(amounts, B_s) + ] + + async def _generate_promise(self, amount, B_): + """Generates a promise for given amount and returns a pair (amount, C').""" + secret_key = self.keys[amount] # Get the correct key + C_ = b_dhke.step2_alice(B_, secret_key) + await store_promise(amount, B_x=B_.x, B_y=B_.y, C_x=C_.x, C_y=C_.y, db=self.db) + return {"amount": amount, "C'": C_} + + def _verify_proof(self, proof): + """Verifies that the proof of promise was issued by this ledger.""" + if proof["secret"] in self.proofs_used: + raise Exception(f"Already spent. Secret: {proof['secret']}") + secret_key = self.keys[proof["amount"]] # Get the correct key to check against + C = Point(proof["C"]["x"], proof["C"]["y"], secp256k1) + return b_dhke.verify(secret_key, C, proof["secret"]) + + def _verify_outputs(self, total, amount, output_data): + """Verifies the expected split was correctly computed""" + fst_amt, snd_amt = total - amount, amount # we have two amounts to split to + fst_outputs = amount_split(fst_amt) + snd_outputs = amount_split(snd_amt) + expected = fst_outputs + snd_outputs + given = [o["amount"] for o in output_data] + return given == expected + + def _verify_no_duplicates(self, proofs, output_data): + secrets = [p["secret"] for p in proofs] + if len(secrets) != len(list(set(secrets))): + return False + B_xs = [od["B'"]["x"] for od in output_data] + if len(B_xs) != len(list(set(B_xs))): + return False + return True + + @staticmethod + def _get_output_split(amount): + """Given an amount returns a list of amounts returned e.g. 13 is [1, 4, 8].""" + bits_amt = bin(amount)[::-1][:-2] + rv = [] + for (pos, bit) in enumerate(bits_amt): + if bit == "1": + rv.append(2**pos) + return rv + + # Public methods + + def get_pubkeys(self): + """Returns public keys for possible amounts.""" + return { + amt: self.keys[amt] * secp256k1.G + for amt in [2**i for i in range(MAX_ORDER)] + } + + async def mint(self, B_, amount): + """Mints a promise for coins for B_.""" + if amount not in [2**i for i in range(MAX_ORDER)]: + raise Exception(f"Can only mint amounts up to {2**MAX_ORDER}.") + split = amount_split(amount) + return [await self._generate_promise(a, B_) for a in split] + + async def split(self, proofs, amount, output_data): + """Consumes proofs and prepares new promises based on the amount split.""" + # Verify proofs are valid + if not all([self._verify_proof(p) for p in proofs]): + return False + + total = sum([p["amount"] for p in proofs]) + + if not self._verify_no_duplicates(proofs, output_data): + raise Exception("duplicate proofs or promises") + if amount > total: + raise Exception("split amount is higher than the total sum") + if not self._verify_outputs(total, amount, output_data): + raise Exception("split of promises is not as expected") + + # Perform split + proof_msgs = set([p["secret"] for p in proofs]) + # Mark proofs as used and prepare new promises + self.proofs_used |= proof_msgs + + # store in db + for p in proofs: + await invalidate_proof(p, db=self.db) + + outs_fst = amount_split(total - amount) + outs_snd = amount_split(amount) + B_fst = [od["B'"] for od in output_data[: len(outs_fst)]] + B_snd = [od["B'"] for od in output_data[len(outs_fst) :]] + return await self._generate_promises( + outs_fst, B_fst + ), await self._generate_promises(outs_snd, B_snd) diff --git a/mint/migrations.py b/mint/migrations.py new file mode 100644 index 0000000..236b7f0 --- /dev/null +++ b/mint/migrations.py @@ -0,0 +1,67 @@ +from core.db import Database + +# from wallet import db + + +async def m001_initial(db: Database): + await db.execute( + """ + CREATE TABLE IF NOT EXISTS promises ( + amount INTEGER NOT NULL, + B_x TEXT NOT NULL, + B_y TEXT NOT NULL, + C_x TEXT NOT NULL, + C_y TEXT NOT NULL, + + UNIQUE (B_x, B_y) + + ); + """ + ) + + await db.execute( + """ + CREATE TABLE IF NOT EXISTS proofs_used ( + amount INTEGER NOT NULL, + C_x TEXT NOT NULL, + C_y TEXT NOT NULL, + secret TEXT NOT NULL, + + UNIQUE (secret) + + ); + """ + ) + + await db.execute( + """ + CREATE VIEW IF NOT EXISTS balance_issued AS + SELECT COALESCE(SUM(s), 0) AS balance FROM ( + SELECT SUM(amount) AS s + FROM promises + WHERE amount > 0 + ); + """ + ) + + await db.execute( + """ + CREATE VIEW IF NOT EXISTS balance_used AS + SELECT COALESCE(SUM(s), 0) AS balance FROM ( + SELECT SUM(amount) AS s + FROM proofs_used + WHERE amount > 0 + ); + """ + ) + + await db.execute( + """ + CREATE VIEW IF NOT EXISTS balance AS + SELECT s_issued - s_used AS balance FROM ( + SELECT bi.balance AS s_issued, bu.balance AS s_used + FROM balance_issued bi + CROSS JOIN balance_used bu + ); + """ + ) diff --git a/test_wallet.py b/test_wallet.py new file mode 100644 index 0000000..3e6d6e3 --- /dev/null +++ b/test_wallet.py @@ -0,0 +1,95 @@ +import asyncio + +from core.helpers import async_unwrap +from wallet.wallet import Wallet as Wallet1 +from wallet.wallet import Wallet as Wallet2 +from wallet.migrations import m001_initial + + +async def run_test(): + SERVER_ENDPOINT = "http://localhost:5000" + wallet1 = Wallet1(SERVER_ENDPOINT, "data/wallet1") + await m001_initial(wallet1.db) + wallet1.status() + + wallet2 = Wallet1(SERVER_ENDPOINT, "data/wallet2") + await m001_initial(wallet2.db) + wallet2.status() + + proofs = [] + + # Mint a proof of promise. We obtain a proof for 64 coins + proofs += await wallet1.mint(64) + print(proofs) + assert wallet1.balance == 64 + wallet1.status() + + # Mint an odd amount (not in 2^n) + proofs += await wallet1.mint(63) + assert wallet1.balance == 64 + 63 + + # Error: We try to split by amount higher than available + w1_fst_proofs, w1_snd_proofs = await wallet1.split(wallet1.proofs, 65) + # assert w1_fst_proofs == [] + # assert w1_snd_proofs == [] + assert wallet1.balance == 63 + 64 + wallet1.status() + + # Error: We try to double-spend by providing a valid proof twice + w1_fst_proofs, w1_snd_proofs = await wallet1.split(wallet1.proofs + proofs, 20) + assert w1_fst_proofs == [] + assert w1_snd_proofs == [] + assert wallet1.balance == 63 + 64 + wallet1.status() + + print("PROOFs") + print(proofs) + w1_fst_proofs, w1_snd_proofs = await wallet1.split(wallet1.proofs, 20) + # we expect 44 and 20 -> [4, 8, 32], [4, 16] + print(w1_fst_proofs) + print(w1_snd_proofs) + # assert [p["amount"] for p in w1_fst_proofs] == [4, 8, 32] + assert [p["amount"] for p in w1_snd_proofs] == [4, 16] + assert wallet1.balance == 63 + 64 + wallet1.status() + + # Error: We try to double-spend and it fails + w1_fst_proofs2_fails, w1_snd_proofs2_fails = await wallet1.split([proofs[0]], 10) + assert w1_fst_proofs2_fails == [] + assert w1_snd_proofs2_fails == [] + assert wallet1.balance == 63 + 64 + wallet1.status() + + # Redeem the tokens in wallet2 + w2_fst_proofs, w2_snd_proofs = await wallet2.redeem(w1_snd_proofs) + print(w2_fst_proofs) + print(w2_snd_proofs) + assert wallet1.balance == 63 + 64 + assert wallet2.balance == 20 + wallet2.status() + + # wallet1 invalidates his proofs + await wallet1.invalidate(w1_snd_proofs) + assert wallet1.balance == 63 + 64 - 20 + wallet1.status() + + w1_fst_proofs2, w1_snd_proofs2 = await wallet1.split(w1_fst_proofs, 5) + # we expect 15 and 5 -> [1, 2, 4, 8], [1, 4] + print(w1_fst_proofs2) + print(w1_snd_proofs2) + assert wallet1.balance == 63 + 64 - 20 + wallet1.status() + + # Error: We try to double-spend and it fails + w1_fst_proofs2, w1_snd_proofs2 = await wallet1.split(w1_snd_proofs, 5) + assert w1_fst_proofs2 == [] + assert w1_snd_proofs2 == [] + assert wallet1.balance == 63 + 64 - 20 + wallet1.status() + + assert wallet1.proof_amounts() == [1, 2, 4, 4, 32, 64] + assert wallet2.proof_amounts() == [4, 16] + + +if __name__ == "__main__": + async_unwrap(run_test()) diff --git a/wallet/__init__.py b/wallet/__init__.py new file mode 100644 index 0000000..c4af010 --- /dev/null +++ b/wallet/__init__.py @@ -0,0 +1,3 @@ +# from core.db import Database + +# db = Database("database", "data/wallet") diff --git a/wallet/crud.py b/wallet/crud.py new file mode 100644 index 0000000..f427adf --- /dev/null +++ b/wallet/crud.py @@ -0,0 +1,69 @@ +import secrets +from typing import Optional +from core.db import Connection, Database + +# from wallet import db +from wallet.models import Proof + + +async def store_proof( + proof: Proof, + db: Database, + conn: Optional[Connection] = None, +): + + await (conn or db).execute( + """ + INSERT INTO proofs + (amount, C_x, C_y, secret) + VALUES (?, ?, ?, ?) + """, + ( + proof["amount"], + str(proof["C"]["x"]), + str(proof["C"]["y"]), + str(proof["secret"]), + ), + ) + + +async def get_proofs( + db: Database, + conn: Optional[Connection] = None, +): + + rows = await (conn or db).fetchall( + """ + SELECT * from proofs + """ + ) + return [Proof.from_row(r) for r in rows] + + +async def invalidate_proof( + proof: dict, + db: Database, + conn: Optional[Connection] = None, +): + + await (conn or db).execute( + f""" + DELETE FROM proofs + WHERE secret = ? + """, + str(proof["secret"]), + ) + + await (conn or db).execute( + """ + INSERT INTO proofs_used + (amount, C_x, C_y, secret) + VALUES (?, ?, ?, ?) + """, + ( + proof["amount"], + str(proof["C"]["x"]), + str(proof["C"]["y"]), + str(proof["secret"]), + ), + ) diff --git a/wallet/migrations.py b/wallet/migrations.py new file mode 100644 index 0000000..914e561 --- /dev/null +++ b/wallet/migrations.py @@ -0,0 +1,55 @@ +from core.db import Database + +# from wallet import db + + +async def m001_initial(db: Database): + await db.execute( + """ + CREATE TABLE IF NOT EXISTS proofs ( + amount INTEGER NOT NULL, + C_x TEXT NOT NULL, + C_y TEXT NOT NULL, + secret TEXT NOT NULL, + + UNIQUE (secret) + + ); + """ + ) + + await db.execute( + """ + CREATE TABLE IF NOT EXISTS proofs_used ( + amount INTEGER NOT NULL, + C_x TEXT NOT NULL, + C_y TEXT NOT NULL, + secret TEXT NOT NULL, + + UNIQUE (secret) + + ); + """ + ) + + await db.execute( + """ + CREATE VIEW IF NOT EXISTS balance AS + SELECT COALESCE(SUM(s), 0) AS balance FROM ( + SELECT SUM(amount) AS s + FROM proofs + WHERE amount > 0 + ); + """ + ) + + await db.execute( + """ + CREATE VIEW IF NOT EXISTS balance_used AS + SELECT COALESCE(SUM(s), 0) AS used FROM ( + SELECT SUM(amount) AS s + FROM proofs_used + WHERE amount > 0 + ); + """ + ) diff --git a/wallet/models.py b/wallet/models.py new file mode 100644 index 0000000..49630fc --- /dev/null +++ b/wallet/models.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel +from sqlite3 import Row + + +class Proof(dict): + amount: int + C_x: int + C_y: int + secret: str + + @classmethod + def from_row(cls, row: Row): + return dict( + amount=row[0], + C=dict( + x=int(row[1]), + y=int(row[2]), + ), + secret=row[3], + ) diff --git a/wallet/wallet.py b/wallet/wallet.py new file mode 100644 index 0000000..1ef1445 --- /dev/null +++ b/wallet/wallet.py @@ -0,0 +1,180 @@ +import random +import asyncio + +import requests +from ecc.curve import secp256k1, Point +from typing import List +from wallet.models import Proof + +import core.b_dhke as b_dhke +from core.db import Database +from core.split import amount_split + +from wallet.crud import store_proof, invalidate_proof, get_proofs + + +class LedgerAPI: + def __init__(self, url): + self.url = url + self.keys = self._get_keys(url) + + @staticmethod + def _get_keys(url): + resp = requests.get(url + "/keys").json() + return { + int(amt): Point(val["x"], val["y"], secp256k1) for amt, val in resp.items() + } + + @staticmethod + def _get_output_split(amount): + """Given an amount returns a list of amounts returned e.g. 13 is [1, 4, 8].""" + bits_amt = bin(amount)[::-1][:-2] + rv = [] + for (pos, bit) in enumerate(bits_amt): + if bit == "1": + rv.append(2**pos) + return rv + + def _construct_proofs(self, promises, secrets): + """Returns proofs of promise from promises.""" + proofs = [] + for promise, (r, secret) in zip(promises, secrets): + C_ = Point(promise["C'"]["x"], promise["C'"]["y"], secp256k1) + C = b_dhke.step3_bob(C_, r, self.keys[promise["amount"]]) + proofs.append( + { + "amount": promise["amount"], + "C": { + "x": C.x, + "y": C.y, + }, + "secret": secret, + } + ) + return proofs + + def mint(self, amount): + """Mints new coins and returns a proof of promise.""" + secret = str(random.getrandbits(128)) + B_, r = b_dhke.step1_bob(secret) + promises = requests.post( + self.url + "/mint", + params={"amount": amount}, + json={"x": str(B_.x), "y": str(B_.y)}, + ).json() + if "error" in promises: + print("Error: {}".format(promises["error"])) + return [] + return self._construct_proofs(promises, [(r, secret)]) + + def split(self, proofs, amount): + """Consume proofs and create new promises based on amount split.""" + total = sum([p["amount"] for p in proofs]) + fst_amt, snd_amt = total - amount, amount + fst_outputs = amount_split(fst_amt) + snd_outputs = amount_split(snd_amt) + + secrets = [] + output_data = [] + for output_amt in fst_outputs + snd_outputs: + secret = str(random.getrandbits(128)) + B_, r = b_dhke.step1_bob(secret) + secrets.append((r, secret)) + output_data.append( + { + "amount": output_amt, + "B'": { + "x": B_.x, + "y": B_.y, + }, + } + ) + promises = requests.post( + self.url + "/split", + json={"proofs": proofs, "amount": amount, "output_data": output_data}, + ).json() + if "error" in promises: + print("Error: {}".format(promises["error"])) + return [], [] + + # Obtain proofs from promises + fst_proofs = self._construct_proofs( + promises["fst"], secrets[: len(promises["fst"])] + ) + snd_proofs = self._construct_proofs( + promises["snd"], secrets[len(promises["fst"]) :] + ) + + return fst_proofs, snd_proofs + + +class Wallet(LedgerAPI): + """Minimal wallet wrapper.""" + + def __init__(self, url: str, db: str): + super().__init__(url) + self.db = Database("wallet", db) + self.proofs: List[Proof] = [] + + async def load_proofs(self): + self.proofs = await get_proofs(db=self.db) + + async def _store_proofs(self, proofs): + for proof in proofs: + await store_proof(proof, db=self.db) + + async def mint(self, amount): + split = amount_split(amount) + new_proofs = [] + for amount in split: + proofs = super().mint(amount) + if proofs == []: + return [] + new_proofs += proofs + await self._store_proofs(proofs) + self.proofs += new_proofs + return new_proofs + + async def redeem(self, proofs): + return await self.split(proofs, sum(p["amount"] for p in proofs)) + + async def split(self, proofs, amount): + fst_proofs, snd_proofs = super().split(proofs, amount) + if len(fst_proofs) == 0 and len(snd_proofs) == 0: + return [], [] + used_secrets = [p["secret"] for p in proofs] + self.proofs = list( + filter(lambda p: p["secret"] not in used_secrets, self.proofs) + ) + self.proofs += fst_proofs + snd_proofs + # store in db + for proof in proofs: + await invalidate_proof(proof, db=self.db) + await self._store_proofs(fst_proofs + snd_proofs) + return fst_proofs, snd_proofs + + async def invalidate(self, proofs): + # first we make sure that the server has invalidated these proofs + fst_proofs, snd_proofs = await self.split( + proofs, sum(p["amount"] for p in proofs) + ) + assert fst_proofs == [] + assert snd_proofs == [] + + # TODO: check with server if they were redeemed already + for proof in proofs: + await invalidate_proof(proof, db=self.db) + invalidate_secrets = [p["secret"] for p in proofs] + self.proofs = list( + filter(lambda p: p["secret"] not in invalidate_secrets, self.proofs) + ) + + @property + def balance(self): + return sum(p["amount"] for p in self.proofs) + + def status(self): + print("Balance: {}".format(self.balance)) + + def proof_amounts(self): + return [p["amount"] for p in sorted(self.proofs, key=lambda p: p["amount"])]