diff --git a/.env.example b/.env.example index accc011..a0831e0 100644 --- a/.env.example +++ b/.env.example @@ -12,5 +12,6 @@ MINT_PRIVATE_KEY=supersecretprivatekey MINT_SERVER_HOST=127.0.0.1 MINT_SERVER_PORT=3338 +LIGHTNING=TRUE LNBITS_ENDPOINT=https://legend.lnbits.com LNBITS_KEY=yourkeyasdasdasd \ No newline at end of file diff --git a/README.md b/README.md index 4f6bbff..d8306fa 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Big thanks to [phyro](https://github.com/phyro) for their work and further discu ## Install ### Prerequisites +These steps help you install Python via pyenv and Poetry. If you already have Poetry running on your computer, you can skip this step and jump right to [Install Cashu](#install-cashu). ```bash sudo apt install -y build-essential pkg-config libffi-dev libpq-dev zlib1g-dev libssl-dev python3-dev # on mac: brew install postgres @@ -46,80 +47,7 @@ vim .env To use the wallet with the [public test mint](#test-instance), you need to change the appropriate entries in the `.env` file. -## Run a mint yourself -This runs the mint on your local computer. Skip this step if you want to use the [public test mint](#test-instance) instead. -```bash -poetry run mint -``` - -## Use wallet - -#### Request a mint - -This command will return a Lightning invoice and a payment hash. You have to pay the invoice before you can receive the tokens. Note: Minting tokens involves two steps: requesting a mint, and actually minting tokens (see below). - -```bash -poetry run cashu mint 420 -``` -Returns: -```bash -Balance: 0 -{ - 'pr': 'lnbc4200n1p3jp5clsp5vcfkyqtnkcx9287auhesqwj40che77pd4ymaltc3ruazh3vcgs3qpp5qzwkavpd4pmfkdmq9trdnrk2lswkt0fypqg55h2sucx6yq9ushzsdq4vdshx6r4ypjx2ur0wd5hgxqyjw5qcqpjrzjq0qly7quwdwq2wr52et5gl65dagdgqdwgn9an58mhejnsvmmu996xzetgvqqwzcqqqqqqqqqqqqqqqqq9q9qyysgqfjwnl4za4naf7l2wwcck2gk6y9mvjt5dz9gptfkpl0j50ygkdkuxyjcy3zgd2tk4995yw8gx39cx2qwm9dgwc0t9t6hrgvjzauykqrqpgw0xx3', - 'hash': '009d6eb02da8769b37602ac6d98ecafc1d65bd2408114a5d50e60da200bc85c5' -} -``` - -#### Mint tokens -After paying the invoice, copy the `hash` value from above and add it to the command -```bash -poetry run cashu mint 420 --hash=009d6eb02da8769b37602ac6d98ecafc1d65bd2408114a5d50e60da200bc85c5 -``` -You should see your balance update accordingly: -```bash -Balance: 0 -Balance: 420 -``` - -#### Check balance -```bash -poetry run cashu balance -``` - -#### Send tokens -To send tokens to another user, enter -```bash -poetry run cashu send 69 -``` -You should see the encoded token. Copy the token and send it to another user such as via email or a messenger. The token looks like this: -```bash -W3siYW1vdW50IjogMSwgIkMiOiB7IngiOiAzMzg0Mzg0NDYzNzAwMTY1NDA2MTQxMDY3Mzg1MDg5MjA2MTU2NjQxMjM4Nzg5MDE4NzAzODg0NjAwNDUzNTAwNzY3... -``` - -#### Receive tokens -To receive tokens, another user enters: -```bash -poetry run cashu receive W3siYW1vdW50IjogMSwgIkMiOi... -``` -You should see the balance increase: -```bash -wallet balance: 0 -wallet balance: 69 -``` - -#### Burn tokens -The sending user needs to burn (invalidate) their tokens from above, otherwise they will try to double spend them (which won't work because the server keeps a list of all spent tokens): -```bash -poetry run cashu burn W3siYW1vdW50IjogMSwgIkMiOi... -``` -Returns: -```bash -wallet balance: 420 -wallet balance: 351 -``` - - -## Test instance +#### Test instance *Warning: this instance is just for demonstration only. Currently, only Lightning deposits work but not withdrawals. The server could vanish at any moment so consider any Satoshis you deposit a donation. I will add Lightning withdrawals soon so unless someone comes up with a huge inflation bug, you might be able to claim them back at a later point in time.* @@ -129,5 +57,95 @@ MINT_HOST=8333.space MINT_PORT=3338 ``` -## Screenshot -![screenshot](https://user-images.githubusercontent.com/93376500/189533335-68a863e2-bacd-47c1-aecc-e4fb09883d11.jpg) +# Using Cashu + +Cashu should be now installed. To execute the following commands, activate your virtual Poetry environment via + +```bash +poetry shell +``` + +If you don't activate your environment, just prepent `poetry run` to all following commands. + +## Using the wallet + +#### Request a mint + +This command will return a Lightning invoice and a payment hash. You have to pay the invoice before you can receive the tokens. Note: Minting tokens involves two steps: requesting a mint, and actually minting tokens (see below). + +```bash +cashu mint 420 +``` +Returns: +```bash +Balance: 0 sat (Available: 0 sat in 0 tokens) +{ + 'pr': 'lnbc4200n1p3jp5clsp5vcfkyqtnkcx9287auhesqwj40che77pd4ymaltc3ruazh3vcgs3qpp5qzwkavpd4pmfkdmq9trdnrk2lswkt0fypqg55h2sucx6yq9ushzsdq4vdshx6r4ypjx2ur0wd5hgxqyjw5qcqpjrzjq0qly7quwdwq2wr52et5gl65dagdgqdwgn9an58mhejnsvmmu996xzetgvqqwzcqqqqqqqqqqqqqqqqq9q9qyysgqfjwnl4za4naf7l2wwcck2gk6y9mvjt5dz9gptfkpl0j50ygkdkuxyjcy3zgd2tk4995yw8gx39cx2qwm9dgwc0t9t6hrgvjzauykqrqpgw0xx3', + 'hash': '009d6eb02da8769b37602ac6d98ecafc1d65bd2408114a5d50e60da200bc85c5' +} +``` + +#### Mint tokens +After paying the invoice, copy the `hash` value from above and add it to the command +```bash +cashu mint 420 --hash=009d6eb02da8769b37602ac6d98ecafc1d65bd2408114a5d50e60da200bc85c5 +``` +You should see your balance update accordingly: +```bash +Balance: 0 sat (Available: 0 sat in 0 tokens) +Balance: 420 sat (Available: 420 sat in 4 tokens) +``` + +Available tokens here means those tokens that have not been reserved for sending. + +#### Check balance +```bash +cashu balance +``` + +#### Send tokens +To send tokens to another user, enter +```bash +cashu send 69 +``` +You should see the encoded token. Copy the token and send it to another user such as via email or a messenger. The token looks like this: +```bash +W3siYW1vdW50IjogMSwgIkMiOiB7IngiOiAzMzg0Mzg0NDYzNzAwMTY1NDA2MTQxMDY3Mzg1MDg5MjA2MTU2NjQxMjM4Nzg5MDE4NzAzODg0NjAwNDUzNTAwNzY3... +``` + +You can now see that your available balance has dropped by the amount that you reserved for sending if you enter `cashu balance`: +```bash +Balance: 420 sat (Available: 351 sat in 7 tokens) +``` + +#### Receive tokens +To receive tokens, another user enters: +```bash +poetry run cashu receive W3siYW1vdW50IjogMSwgIkMiOi... +``` +You should see the balance increase: +```bash +Balance: 0 sat (Available: 0 sat in 0 tokens) +Balance: 69 sat (Available: 69 sat in 3 tokens) +``` + +#### Burn tokens +The sending user needs to burn (invalidate) their tokens from above, otherwise they will try to double spend them (which won't work because the server keeps a list of all spent tokens): +```bash +poetry run cashu burn W3siYW1vdW50IjogMSwgIkMiOi... +``` +Returns: +```bash +Balance: 420 sat (Available: 351 sat in 7 tokens) +Balance: 351 sat (Available: 351 sat in 7 tokens) +``` + + +## Run a mint yourself +This command runs the mint on your local computer. Skip this step if you want to use the [public test mint](#test-instance) instead. +```bash +mint +``` + +You can turn off Lightning support and mint as many tokens as you like by setting `LIGHTNING=FALSE` in the `.env` file. + diff --git a/core/base.py b/core/base.py index 6fba403..c213bb8 100644 --- a/core/base.py +++ b/core/base.py @@ -15,18 +15,44 @@ class Proof(BaseModel): amount: int C: BasePoint secret: str + reserved: bool = False # whether this proof is reserved for sending @classmethod def from_row(cls, row: Row): - return dict( + return cls( amount=row[0], C=dict( x=int(row[1]), y=int(row[2]), ), secret=row[3], + reserved=row[4] or False, ) + @classmethod + def from_dict(cls, d: dict): + return cls( + amount=d["amount"], + C=dict( + x=int(d["C"]["x"]), + y=int(d["C"]["y"]), + ), + secret=d["secret"], + reserved=d["reserved"] or False, + ) + + def __getitem__(self, key): + return self.__getattribute__(key) + + def __setitem__(self, key, val): + self.__setattr__(key, val) + + +class Proofs(BaseModel): + """TODO: Use this model""" + + proofs: List[Proof] + class Invoice(BaseModel): amount: int diff --git a/core/settings.py b/core/settings.py index 6f1b81e..bff15eb 100644 --- a/core/settings.py +++ b/core/settings.py @@ -4,6 +4,7 @@ env = Env() env.read_env() DEBUG = env.bool("DEBUG", default=False) +LIGHTNING = env.bool("LIGHTNING", default=False) MINT_PRIVATE_KEY = env.str("MINT_PRIVATE_KEY") diff --git a/mint/migrations.py b/mint/migrations.py index d4c2435..87b48c7 100644 --- a/mint/migrations.py +++ b/mint/migrations.py @@ -1,6 +1,15 @@ from core.db import Database -# from wallet import db + +async def m000_create_migrations_table(db): + await db.execute( + """ + CREATE TABLE IF NOT EXISTS dbversions ( + db TEXT PRIMARY KEY, + version INT NOT NULL + ) + """ + ) async def m001_initial(db: Database): diff --git a/wallet/cashu.py b/wallet/cashu.py index 694c177..45298cf 100755 --- a/wallet/cashu.py +++ b/wallet/cashu.py @@ -8,14 +8,16 @@ from functools import wraps import click from bech32 import bech32_decode, bech32_encode, convertbits -from core.settings import MINT_URL -from wallet.migrations import m001_initial +from core.settings import MINT_URL, LIGHTNING +from core.migrations import migrate_databases +from core.base import Proof from wallet.wallet import Wallet as Wallet +from wallet import migrations async def init_wallet(wallet: Wallet): """Performs migrations and loads proofs from db.""" - await m001_initial(db=wallet.db) + await migrate_databases(wallet.db, migrations) await wallet.load_proofs() @@ -58,17 +60,25 @@ def coro(f): @coro async def mint(ctx, amount: int, hash: str): wallet: Wallet = ctx.obj["WALLET"] - await m001_initial(db=wallet.db) - await wallet.load_proofs() + await init_wallet(wallet) + + if not LIGHTNING: + wallet.status() + r = await wallet.mint(amount) + wallet.status() + return + if amount and not hash: print(f"Balance: {wallet.balance}") r = await wallet.request_mint(amount) print(r) + return if amount and hash: print(f"Balance: {wallet.balance}") await wallet.mint(amount, hash) print(f"Balance: {wallet.balance}") + return @cli.command("balance", help="See balance.") @@ -88,8 +98,11 @@ async def send(ctx, amount: int): wallet: Wallet = ctx.obj["WALLET"] await init_wallet(wallet) wallet.status() - _, send_proofs = await wallet.split(wallet.proofs, amount) - print(base64.urlsafe_b64encode(json.dumps(send_proofs).encode()).decode()) + _, send_proofs = await wallet.split_to_send(wallet.proofs, amount) + await wallet.set_reserved(send_proofs, reserved=True) + proofs_serialized = [p.dict() for p in send_proofs] + print(base64.urlsafe_b64encode(json.dumps(proofs_serialized).encode()).decode()) + wallet.status() @cli.command("receive", help="Receive tokens.") @@ -100,7 +113,7 @@ async def receive(ctx, token: str): wallet: Wallet = ctx.obj["WALLET"] await init_wallet(wallet) wallet.status() - proofs = json.loads(base64.urlsafe_b64decode(token)) + proofs = [Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(token))] _, _ = await wallet.redeem(proofs) wallet.status() @@ -113,6 +126,6 @@ async def receive(ctx, token: str): wallet: Wallet = ctx.obj["WALLET"] await init_wallet(wallet) wallet.status() - proofs = json.loads(base64.urlsafe_b64decode(token)) + proofs = [Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(token))] await wallet.invalidate(proofs) wallet.status() diff --git a/wallet/crud.py b/wallet/crud.py index a15353b..4744685 100644 --- a/wallet/crud.py +++ b/wallet/crud.py @@ -1,7 +1,5 @@ import secrets from typing import Optional - -# from wallet import db from core.base import Proof from core.db import Connection, Database @@ -19,10 +17,10 @@ async def store_proof( VALUES (?, ?, ?, ?) """, ( - proof["amount"], - str(proof["C"]["x"]), - str(proof["C"]["y"]), - str(proof["secret"]), + proof.amount, + str(proof.C.x), + str(proof.C.y), + str(proof.secret), ), ) @@ -41,7 +39,7 @@ async def get_proofs( async def invalidate_proof( - proof: dict, + proof: Proof, db: Database, conn: Optional[Connection] = None, ): @@ -61,9 +59,21 @@ async def invalidate_proof( VALUES (?, ?, ?, ?) """, ( - proof["amount"], - str(proof["C"]["x"]), - str(proof["C"]["y"]), - str(proof["secret"]), + proof.amount, + str(proof.C.x), + str(proof.C.y), + str(proof.secret), ), ) + + +async def update_proof_reserved( + proof: Proof, + reserved: bool, + db: Database, + conn: Optional[Connection] = None, +): + await (conn or db).execute( + "UPDATE proofs SET reserved = ? WHERE secret = ?", + (reserved, str(proof.secret)), + ) diff --git a/wallet/migrations.py b/wallet/migrations.py index 914e561..4ffd14e 100644 --- a/wallet/migrations.py +++ b/wallet/migrations.py @@ -1,6 +1,15 @@ from core.db import Database -# from wallet import db + +async def m000_create_migrations_table(db): + await db.execute( + """ + CREATE TABLE IF NOT EXISTS dbversions ( + db TEXT PRIMARY KEY, + version INT NOT NULL + ) + """ + ) async def m001_initial(db: Database): @@ -53,3 +62,11 @@ async def m001_initial(db: Database): ); """ ) + + +async def m002_add_proofs_reserved(db): + """ + Column for marking proofs as reserved when they are being sent. + """ + + await db.execute("ALTER TABLE proofs ADD COLUMN reserved BOOL") diff --git a/wallet/wallet.py b/wallet/wallet.py index c31cd6c..616a581 100644 --- a/wallet/wallet.py +++ b/wallet/wallet.py @@ -8,7 +8,7 @@ import core.b_dhke as b_dhke from core.base import BasePoint, MintPayload, MintPayloads, Proof, SplitPayload from core.db import Database from core.split import amount_split -from wallet.crud import get_proofs, invalidate_proof, store_proof +from wallet.crud import get_proofs, invalidate_proof, store_proof, update_proof_reserved class LedgerAPI: @@ -41,7 +41,7 @@ class LedgerAPI: C = b_dhke.step3_bob(C_, r, self.keys[promise["amount"]]) c_point = BasePoint(x=C.x, y=C.y) proof = Proof(amount=promise["amount"], C=c_point, secret=secret) - proofs.append(proof.dict()) + proofs.append(proof) return proofs def request_mint(self, amount): @@ -135,10 +135,10 @@ class Wallet(LedgerAPI): self.proofs += proofs return proofs - async def redeem(self, proofs): + async def redeem(self, proofs: List[Proof]): return await self.split(proofs, sum(p["amount"] for p in proofs)) - async def split(self, proofs, amount): + async def split(self, proofs: List[Proof], amount: int): assert len(proofs) > 0, ValueError("no proofs provided.") fst_proofs, snd_proofs = super().split(proofs, amount) if len(fst_proofs) == 0 and len(snd_proofs) == 0: @@ -148,12 +148,24 @@ class Wallet(LedgerAPI): 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 split_to_send(self, proofs: List[Proof], amount): + """Like self.split but only considers non-reserved tokens.""" + if len([p for p in proofs if not p.reserved]) <= 0: + raise Exception("balance too low") + return await self.split([p for p in proofs if not p.reserved], amount) + + async def set_reserved(self, proofs: List[Proof], reserved: bool): + """Mark a proof as reserved to avoid reuse or delete marking.""" + for proof in proofs: + proof.reserved = True + await update_proof_reserved(proof, reserved=reserved, db=self.db) + async def invalidate(self, proofs): # first we make sure that the server has invalidated these proofs try: @@ -175,8 +187,14 @@ class Wallet(LedgerAPI): def balance(self): return sum(p["amount"] for p in self.proofs) + @property + def available_balance(self): + return sum(p["amount"] for p in self.proofs if not p.reserved) + def status(self): - print(f"{self.name} balance: {self.balance}") + print( + f"Balance: {self.balance} sat (Available: {self.available_balance} sat in {len([p for p in self.proofs if not p.reserved])} tokens)" + ) def proof_amounts(self): return [p["amount"] for p in sorted(self.proofs, key=lambda p: p["amount"])]