From f4777aad3ef0921139efabb291ad1e7f30d44862 Mon Sep 17 00:00:00 2001 From: findingsov <113792100+findingsov@users.noreply.github.com> Date: Thu, 14 Mar 2024 07:16:33 -0400 Subject: [PATCH 01/16] Update .env.example (#455) StrikeWallet is StrikeUSDWallet, changed in 2 places, and added that it's usd only (in case it's not obvious). --- .env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index e51c570c..d8e1b7cd 100644 --- a/.env.example +++ b/.env.example @@ -56,7 +56,7 @@ MINT_DERIVATION_PATH="m/0'/0'/0'" MINT_DATABASE=data/mint # Lightning -# Supported: FakeWallet, LndRestWallet, CoreLightningRestWallet, BlinkWallet, LNbitsWallet, StrikeWallet +# Supported: FakeWallet, LndRestWallet, CoreLightningRestWallet, BlinkWallet, LNbitsWallet, StrikeUSDWallet MINT_LIGHTNING_BACKEND=FakeWallet # for use with LNbitsWallet @@ -76,7 +76,7 @@ MINT_CORELIGHTNING_REST_CERT="./clightning-2-rest/certificate.pem" # Use with BlinkWallet MINT_BLINK_KEY=blink_abcdefgh -# Use with StrikeWallet +# Use with StrikeUSDWallet (usd only, does not currently support sats/msats) MINT_STRIKE_KEY=ABC123 # fee to reserve in percent of the amount From 6cb967fc026dcbce9389be2336ff1493022bcb03 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Thu, 14 Mar 2024 11:17:42 +0000 Subject: [PATCH 02/16] fix: mint and melt quote expiry time (#453) --- cashu/mint/ledger.py | 12 ++++++++++-- tests/test_mint_api.py | 14 ++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 532df73f..1849d3cb 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -343,6 +343,10 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: # get invoice expiry time invoice_obj = bolt11.decode(invoice_response.payment_request) + expiry = None + if invoice_obj.expiry is not None: + expiry = invoice_obj.date + invoice_obj.expiry + quote = MintQuote( quote=random_hash(), method=method.name, @@ -353,7 +357,7 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: issued=False, paid=False, created_time=int(time.time()), - expiry=invoice_obj.expiry, + expiry=expiry, ) await self.crud.store_mint_quote( quote=quote, @@ -499,6 +503,10 @@ async def melt_quote( melt_quote.request ) assert payment_quote.checking_id, "quote has no checking id" + + expiry = None + if invoice_obj.expiry is not None: + expiry = invoice_obj.date + invoice_obj.expiry quote = MeltQuote( quote=random_hash(), @@ -510,7 +518,7 @@ async def melt_quote( paid=False, fee_reserve=payment_quote.fee.to(unit).amount, created_time=int(time.time()), - expiry=invoice_obj.expiry, + expiry=expiry, ) await self.crud.store_melt_quote(quote=quote, db=self.db) return PostMeltQuoteResponse( diff --git a/tests/test_mint_api.py b/tests/test_mint_api.py index 6685af30..5a571416 100644 --- a/tests/test_mint_api.py +++ b/tests/test_mint_api.py @@ -183,7 +183,12 @@ async def test_mint_quote(ledger: Ledger): assert result["request"] invoice = bolt11.decode(result["request"]) assert invoice.amount_msat == 100 * 1000 - assert result["expiry"] == invoice.expiry + + expiry = None + if invoice.expiry is not None: + expiry = invoice.date + invoice.expiry + + assert result["expiry"] == expiry # get mint quote again from api response = httpx.get( @@ -246,7 +251,12 @@ async def test_melt_quote_internal(ledger: Ledger, wallet: Wallet): # TODO: internal invoice, fee should be 0 assert result["fee_reserve"] == 0 invoice_obj = bolt11.decode(request) - assert result["expiry"] == invoice_obj.expiry + + expiry = None + if invoice_obj.expiry is not None: + expiry = invoice_obj.date + invoice_obj.expiry + + assert result["expiry"] == expiry # get melt quote again from api response = httpx.get( From 752ab3c4c54b755933116f5e2d1d15aba0bc0fe4 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 14 Mar 2024 12:21:21 +0100 Subject: [PATCH 03/16] Fix seed encrypt migration (#405) * print * print before ledger * print * remove decryption * make format --- cashu/mint/decrypt.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/cashu/mint/decrypt.py b/cashu/mint/decrypt.py index 71247bf6..53c4b63f 100644 --- a/cashu/mint/decrypt.py +++ b/cashu/mint/decrypt.py @@ -68,14 +68,7 @@ def decrypt(encrypted, key): @click.option("--no-dry-run", is_flag=True, help="Dry run.", default=False) async def migrate(no_dry_run): """Migrate the database to encrypted seeds.""" - ledger = Ledger( - db=Database("mint", settings.mint_database), - seed=settings.mint_private_key, - seed_decryption_key=settings.mint_seed_decryption_key, - derivation_path=settings.mint_derivation_path, - backends={}, - crud=LedgerCrudSqlite(), - ) + click.echo(f"Database: directory: {settings.mint_database}") assert settings.mint_seed_decryption_key, "MINT_SEED_DECRYPTION_KEY not set." assert ( len(settings.mint_seed_decryption_key) > 12 @@ -84,7 +77,16 @@ async def migrate(no_dry_run): "Decryption key:" f" {settings.mint_seed_decryption_key[0]}{'*'*10}{settings.mint_seed_decryption_key[-1]}" ) - + click.echo( + f"Seed: {settings.mint_private_key[0]}{'*'*10}{settings.mint_private_key[-1]}" + ) + ledger = Ledger( + db=Database("mint", settings.mint_database), + seed=settings.mint_private_key, + derivation_path=settings.mint_derivation_path, + backends={}, + crud=LedgerCrudSqlite(), + ) aes = AESCipher(settings.mint_seed_decryption_key) click.echo("Making sure that db is migrated to latest version first.") From 5ba19a17cff1bff47107db5a7c158559be5d6e7c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 14 Mar 2024 12:48:24 +0100 Subject: [PATCH 04/16] Seed encrypt: init mint with encrypted keys after migration (#472) * seed encrypt: init mint with encrypted keys after migration * adjust build pipeline --- .github/workflows/docker.yaml | 12 ++++++++++++ cashu/core/crypto/keys.py | 18 ++++++++---------- cashu/core/legacy.py | 7 +++---- cashu/mint/{decrypt.py => encrypt.py} | 11 +++++++++++ cashu/mint/ledger.py | 23 ++++++++++++++--------- 5 files changed, 48 insertions(+), 23 deletions(-) rename cashu/mint/{decrypt.py => encrypt.py} (91%) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index ded34be9..fb6ada58 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -21,6 +21,15 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Cache Docker layers + uses: actions/cache@v4 + id: cache + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + - name: Determine Tag id: get_tag run: | @@ -36,3 +45,6 @@ jobs: context: . push: ${{ github.event_name == 'release' }} tags: ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }}:${{ steps.get_tag.outputs.tag }} + platforms: linux/amd64,linux/arm64 + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache diff --git a/cashu/core/crypto/keys.py b/cashu/core/crypto/keys.py index 65869cba..36fb7974 100644 --- a/cashu/core/crypto/keys.py +++ b/cashu/core/crypto/keys.py @@ -16,8 +16,7 @@ def derive_keys(mnemonic: str, derivation_path: str): bip32 = BIP32.from_seed(mnemonic.encode()) orders_str = [f"/{i}'" for i in range(settings.max_order)] return { - 2 - ** i: PrivateKey( + 2**i: PrivateKey( bip32.get_privkey_from_path(derivation_path + orders_str[i]), raw=True, ) @@ -25,26 +24,25 @@ def derive_keys(mnemonic: str, derivation_path: str): } -def derive_keys_sha256(master_key: str, derivation_path: str = ""): +def derive_keys_sha256(seed: str, derivation_path: str = ""): """ Deterministic derivation of keys for 2^n values. TODO: Implement BIP32. """ return { - 2 - ** i: PrivateKey( - hashlib.sha256( - (master_key + derivation_path + str(i)).encode("utf-8") - ).digest()[:32], + 2**i: PrivateKey( + hashlib.sha256((seed + derivation_path + str(i)).encode("utf-8")).digest()[ + :32 + ], raw=True, ) for i in range(settings.max_order) } -def derive_pubkey(master_key: str): +def derive_pubkey(seed: str): return PrivateKey( - hashlib.sha256((master_key).encode("utf-8")).digest()[:32], + hashlib.sha256((seed).encode("utf-8")).digest()[:32], raw=True, ).pubkey diff --git a/cashu/core/legacy.py b/cashu/core/legacy.py index 87c64e96..7123476e 100644 --- a/cashu/core/legacy.py +++ b/cashu/core/legacy.py @@ -6,15 +6,14 @@ def derive_keys_backwards_compatible_insecure_pre_0_12( - master_key: str, derivation_path: str = "" + seed: str, derivation_path: str = "" ): """ WARNING: Broken key derivation for backwards compatibility with 0.11. """ return { - 2 - ** i: PrivateKey( - hashlib.sha256((master_key + derivation_path + str(i)).encode("utf-8")) + 2**i: PrivateKey( + hashlib.sha256((seed + derivation_path + str(i)).encode("utf-8")) .hexdigest() .encode("utf-8")[:32], raw=True, diff --git a/cashu/mint/decrypt.py b/cashu/mint/encrypt.py similarity index 91% rename from cashu/mint/decrypt.py rename to cashu/mint/encrypt.py index 53c4b63f..adf0113a 100644 --- a/cashu/mint/decrypt.py +++ b/cashu/mint/encrypt.py @@ -146,6 +146,17 @@ async def migrate(no_dry_run): keyset_dict["id"], ), ) + + click.echo("Initializing mint with encrypted seeds.") + encrypted_mint_private_key = aes.encrypt(settings.mint_private_key.encode()) + ledger = Ledger( + db=Database("mint", settings.mint_database), + seed=encrypted_mint_private_key, + seed_decryption_key=settings.mint_seed_decryption_key, + derivation_path=settings.mint_derivation_path, + backends={}, + crud=LedgerCrudSqlite(), + ) click.echo("✅ Migration complete.") diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 1849d3cb..0035f24a 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -76,17 +76,22 @@ def __init__( assert seed, "seed not set" # decrypt seed if seed_decryption_key is set - self.master_key = ( - AESCipher(seed_decryption_key).decrypt(seed) - if seed_decryption_key - else seed - ) + try: + self.seed = ( + AESCipher(seed_decryption_key).decrypt(seed) + if seed_decryption_key + else seed + ) + except Exception as e: + raise Exception( + f"Could not decrypt seed. Make sure that the seed is correct and the decryption key is set. {e}" + ) self.derivation_path = derivation_path self.db = db self.crud = crud self.backends = backends - self.pubkey = derive_pubkey(self.master_key) + self.pubkey = derive_pubkey(self.seed) self.spent_proofs: Dict[str, Proof] = {} # ------- KEYS ------- @@ -109,7 +114,7 @@ async def activate_keyset( MintKeyset: Keyset """ assert derivation_path, "derivation path not set" - seed = seed or self.master_key + seed = seed or self.seed tmp_keyset_local = MintKeyset( seed=seed, derivation_path=derivation_path, @@ -132,7 +137,7 @@ async def activate_keyset( # no keyset for this derivation path yet # we create a new keyset (keys will be generated at instantiation) keyset = MintKeyset( - seed=seed or self.master_key, + seed=seed or self.seed, derivation_path=derivation_path, version=version or settings.version, ) @@ -503,7 +508,7 @@ async def melt_quote( melt_quote.request ) assert payment_quote.checking_id, "quote has no checking id" - + expiry = None if invoice_obj.expiry is not None: expiry = invoice_obj.date + invoice_obj.expiry From 7ecf14f87b7dc28209527b1ede702a3fe5c9a5bc Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 14 Mar 2024 13:17:05 +0100 Subject: [PATCH 05/16] Tests: mint restore api test (#473) * tests: mint restore api test * skipifs for deprecated tests * use correct secret counter for tests * docker only on release --- .github/workflows/docker.yaml | 1 - cashu/mint/ledger.py | 6 ++--- cashu/mint/router.py | 4 +-- tests/test_mint_api.py | 44 ++++++++++++++++++++++++++++--- tests/test_mint_api_deprecated.py | 38 +++++++++++++++++++++++--- 5 files changed, 80 insertions(+), 13 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index fb6ada58..265ebba0 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -1,7 +1,6 @@ name: Docker Build on: - push: release: types: [published] diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 0035f24a..b62072dd 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -799,7 +799,7 @@ async def split( async def restore( self, outputs: List[BlindedMessage] ) -> Tuple[List[BlindedMessage], List[BlindedSignature]]: - promises: List[BlindedSignature] = [] + signatures: List[BlindedSignature] = [] return_outputs: List[BlindedMessage] = [] async with self.db.connect() as conn: for output in outputs: @@ -814,10 +814,10 @@ async def restore( if not promise.id and len(self.keysets) == 1: promise.id = self.keyset.id # END backwards compatibility - promises.append(promise) + signatures.append(promise) return_outputs.append(output) logger.trace(f"promise found: {promise}") - return return_outputs, promises + return return_outputs, signatures # ------- BLIND SIGNATURES ------- diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 4c96d162..58af4203 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -354,5 +354,5 @@ async def check_state( ) async def restore(payload: PostMintRequest) -> PostRestoreResponse: assert payload.outputs, Exception("no outputs provided.") - outputs, promises = await ledger.restore(payload.outputs) - return PostRestoreResponse(outputs=outputs, signatures=promises) + outputs, signatures = await ledger.restore(payload.outputs) + return PostRestoreResponse(outputs=outputs, signatures=signatures) diff --git a/tests/test_mint_api.py b/tests/test_mint_api.py index 5a571416..b5e5b64b 100644 --- a/tests/test_mint_api.py +++ b/tests/test_mint_api.py @@ -6,10 +6,13 @@ from cashu.core.base import ( PostCheckStateRequest, PostCheckStateResponse, + PostMintRequest, + PostRestoreResponse, SpentState, ) from cashu.core.settings import settings from cashu.mint.ledger import Ledger +from cashu.wallet.crud import bump_secret_derivation from cashu.wallet.wallet import Wallet from tests.helpers import get_real_invoice, is_fake, is_regtest, pay_if_regtest @@ -183,11 +186,11 @@ async def test_mint_quote(ledger: Ledger): assert result["request"] invoice = bolt11.decode(result["request"]) assert invoice.amount_msat == 100 * 1000 - + expiry = None if invoice.expiry is not None: expiry = invoice.date + invoice.expiry - + assert result["expiry"] == expiry # get mint quote again from api @@ -251,7 +254,7 @@ async def test_melt_quote_internal(ledger: Ledger, wallet: Wallet): # TODO: internal invoice, fee should be 0 assert result["fee_reserve"] == 0 invoice_obj = bolt11.decode(request) - + expiry = None if invoice_obj.expiry is not None: expiry = invoice_obj.date + invoice_obj.expiry @@ -399,3 +402,38 @@ async def test_api_check_state(ledger: Ledger): assert response assert len(response.states) == 2 assert response.states[0].state == SpentState.unspent + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_api_restore(ledger: Ledger, wallet: Wallet): + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + secret_counter = await bump_secret_derivation( + db=wallet.db, keyset_id=wallet.keyset_id, by=0, skip=True + ) + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to( + secret_counter - 1, secret_counter - 1 + ) + outputs, rs = wallet._construct_outputs([64], secrets, rs) + + payload = PostMintRequest(outputs=outputs, quote="placeholder") + response = httpx.post( + f"{BASE_URL}/v1/restore", + json=payload.dict(), + ) + data = response.json() + assert "signatures" in data + assert "outputs" in data + assert response.status_code == 200, f"{response.url} {response.status_code}" + response = PostRestoreResponse.parse_obj(response.json()) + assert response + assert response + assert len(response.signatures) == 1 + assert len(response.outputs) == 1 + assert response.outputs == outputs diff --git a/tests/test_mint_api_deprecated.py b/tests/test_mint_api_deprecated.py index fcdcf8cd..478b5ba4 100644 --- a/tests/test_mint_api_deprecated.py +++ b/tests/test_mint_api_deprecated.py @@ -5,10 +5,13 @@ from cashu.core.base import ( CheckSpendableRequest_deprecated, CheckSpendableResponse_deprecated, + PostMintRequest, + PostRestoreResponse, Proof, ) from cashu.core.settings import settings from cashu.mint.ledger import Ledger +from cashu.wallet.crud import bump_secret_derivation from cashu.wallet.wallet import Wallet from tests.helpers import get_real_invoice, is_fake, is_regtest, pay_if_regtest @@ -305,10 +308,6 @@ async def test_checkfees_external(ledger: Ledger, wallet: Wallet): @pytest.mark.asyncio -@pytest.mark.skipif( - settings.debug_mint_only_deprecated, - reason="settings.debug_mint_only_deprecated is set", -) async def test_api_check_state(ledger: Ledger): proofs = [ Proof(id="1234", amount=0, secret="asdasdasd", C="asdasdasd"), @@ -325,3 +324,34 @@ async def test_api_check_state(ledger: Ledger): assert len(states.spendable) == 2 assert states.pending assert len(states.pending) == 2 + + +@pytest.mark.asyncio +async def test_api_restore(ledger: Ledger, wallet: Wallet): + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + secret_counter = await bump_secret_derivation( + db=wallet.db, keyset_id=wallet.keyset_id, by=0, skip=True + ) + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to( + secret_counter - 1, secret_counter - 1 + ) + outputs, rs = wallet._construct_outputs([64], secrets, rs) + + payload = PostMintRequest(outputs=outputs, quote="placeholder") + response = httpx.post( + f"{BASE_URL}/restore", + json=payload.dict(), + ) + data = response.json() + assert "promises" in data + assert "outputs" in data + assert response.status_code == 200, f"{response.url} {response.status_code}" + response = PostRestoreResponse.parse_obj(response.json()) + assert response + assert response.promises + assert len(response.promises) == 1 + assert len(response.outputs) == 1 + assert response.outputs == outputs From e7b1e0c0edd4be2b628da59f8d20e555bde77a42 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 16 Mar 2024 10:56:51 +0100 Subject: [PATCH 06/16] Batch update Y columns in proofs_used and proofs_pending tables (#475) * mint: migration 16 batch update * migrate: only if data exists --- cashu/mint/migrations.py | 53 ++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index f215b2a2..2291288c 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -555,15 +555,54 @@ async def m016_recompute_Y_with_new_h2c(db: Database): ) proofs_pending = [Proof(**r) for r in rows] - # overwrite the old Y columns with the new Y + # Prepare data for batch update + proofs_used_data = [(proof.Y, proof.secret) for proof in proofs_used] + proofs_pending_data = [(proof.Y, proof.secret) for proof in proofs_pending] + + # Perform batch update in a single transaction async with db.connect() as conn: - for proof in proofs_used: + if len(proofs_used_data): + # For proofs_used await conn.execute( - f"UPDATE {table_with_schema(db, 'proofs_used')} SET Y = '{proof.Y}'" - f" WHERE secret = '{proof.secret}'" + "CREATE TABLE IF NOT EXISTS tmp_proofs_used (Y TEXT, secret TEXT)" + ) + values_placeholder = ", ".join( + f"('{y}', '{secret}')" for y, secret in proofs_used_data ) - for proof in proofs_pending: await conn.execute( - f"UPDATE {table_with_schema(db, 'proofs_pending')} SET Y = '{proof.Y}'" - f" WHERE secret = '{proof.secret}'" + f"INSERT INTO tmp_proofs_used (Y, secret) VALUES {values_placeholder}", + ) + await conn.execute( + f""" + UPDATE {table_with_schema(db, 'proofs_used')} + SET Y = tmp_proofs_used.Y + FROM tmp_proofs_used + WHERE {table_with_schema(db, 'proofs_used')}.secret = tmp_proofs_used.secret + """ ) + + if len(proofs_pending_data): + # For proofs_pending + await conn.execute( + "CREATE TABLE IF NOT EXISTS tmp_proofs_pending (Y TEXT, secret TEXT)" + ) + values_placeholder = ", ".join( + f"('{y}', '{secret}')" for y, secret in proofs_pending_data + ) + await conn.execute( + f"INSERT INTO tmp_proofs_used (Y, secret) VALUES {values_placeholder}", + ) + await conn.execute( + f""" + UPDATE {table_with_schema(db, 'proofs_pending')} + SET Y = tmp_proofs_pending.Y + FROM tmp_proofs_pending + WHERE {table_with_schema(db, 'proofs_pending')}.secret = tmp_proofs_pending.secret + """ + ) + + async with db.connect() as conn: + if len(proofs_used_data): + await conn.execute("DROP TABLE tmp_proofs_used") + if len(proofs_pending_data): + await conn.execute("DROP TABLE tmp_proofs_pending") From 86b8f5811f3d897cb788c3d5124f9f8c7a25aecd Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 16 Mar 2024 17:14:50 +0100 Subject: [PATCH 07/16] Mint: lookup internal quote settlement by request (#478) --- cashu/mint/crud.py | 12 ++++++------ cashu/mint/ledger.py | 12 ++++++------ tests/test_mint_db.py | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 9a1dd54c..75f4471c 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -155,10 +155,10 @@ async def get_mint_quote( ... @abstractmethod - async def get_mint_quote_by_checking_id( + async def get_mint_quote_by_request( self, *, - checking_id: str, + request: str, db: Database, conn: Optional[Connection] = None, ) -> Optional[MintQuote]: @@ -403,19 +403,19 @@ async def get_mint_quote( ) return MintQuote.from_row(row) if row else None - async def get_mint_quote_by_checking_id( + async def get_mint_quote_by_request( self, *, - checking_id: str, + request: str, db: Database, conn: Optional[Connection] = None, ) -> Optional[MintQuote]: row = await (conn or db).fetchone( f""" SELECT * from {table_with_schema(db, 'mint_quotes')} - WHERE checking_id = ? + WHERE request = ? """, - (checking_id,), + (request,), ) return MintQuote.from_row(row) if row else None diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index b62072dd..8f8a2316 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -475,8 +475,8 @@ async def melt_quote( # check if there is a mint quote with the same payment request # so that we can handle the transaction internally without lightning # and respond with zero fees - mint_quote = await self.crud.get_mint_quote_by_checking_id( - checking_id=invoice_obj.payment_hash, db=self.db + mint_quote = await self.crud.get_mint_quote_by_request( + request=melt_quote.request, db=self.db ) if mint_quote: # internal transaction, validate and return amount from @@ -561,8 +561,8 @@ async def get_melt_quote( # we only check the state with the backend if there is no associated internal # mint quote for this melt quote - mint_quote = await self.crud.get_mint_quote_by_checking_id( - checking_id=melt_quote.checking_id, db=self.db + mint_quote = await self.crud.get_mint_quote_by_request( + request=melt_quote.request, db=self.db ) if not melt_quote.paid and not mint_quote and check_quote_with_backend: @@ -600,8 +600,8 @@ async def melt_mint_settle_internally(self, melt_quote: MeltQuote) -> MeltQuote: """ # first we check if there is a mint quote with the same payment request # so that we can handle the transaction internally without the backend - mint_quote = await self.crud.get_mint_quote_by_checking_id( - checking_id=melt_quote.checking_id, db=self.db + mint_quote = await self.crud.get_mint_quote_by_request( + request=melt_quote.request, db=self.db ) if not mint_quote: return melt_quote diff --git a/tests/test_mint_db.py b/tests/test_mint_db.py index 1d006a2f..92caf1e7 100644 --- a/tests/test_mint_db.py +++ b/tests/test_mint_db.py @@ -46,6 +46,22 @@ async def test_mint_quote(wallet1: Wallet, ledger: Ledger): assert quote.created_time +@pytest.mark.asyncio +async def test_get_mint_quote_by_request(wallet1: Wallet, ledger: Ledger): + invoice = await wallet1.request_mint(128) + assert invoice is not None + quote = await ledger.crud.get_mint_quote_by_request( + request=invoice.bolt11, db=ledger.db + ) + assert quote is not None + assert quote.quote == invoice.id + assert quote.amount == 128 + assert quote.unit == "sat" + assert not quote.paid + assert quote.paid_time is None + assert quote.created_time + + @pytest.mark.asyncio async def test_melt_quote(wallet1: Wallet, ledger: Ledger): invoice = await wallet1.request_mint(128) From 30cdb81041d6478502d21f17ae20762b54f80672 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 16 Mar 2024 18:45:53 +0100 Subject: [PATCH 08/16] Melt quotes use case-insensitive request (#479) * use lowerstr melt quote * case insensitive melt quotes --- cashu/mint/ledger.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 8f8a2316..3bb0cb8a 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -348,6 +348,10 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: # get invoice expiry time invoice_obj = bolt11.decode(invoice_response.payment_request) + # NOTE: we normalize the request to lowercase to avoid case sensitivity + # This works with Lightning but might not work with other methods + request = invoice_response.payment_request.lower() + expiry = None if invoice_obj.expiry is not None: expiry = invoice_obj.date + invoice_obj.expiry @@ -355,7 +359,7 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: quote = MintQuote( quote=random_hash(), method=method.name, - request=invoice_response.payment_request, + request=request, checking_id=invoice_response.checking_id, unit=quote_request.unit, amount=quote_request.amount, @@ -469,6 +473,9 @@ async def melt_quote( """ unit = Unit[melt_quote.unit] method = Method.bolt11 + # NOTE: we normalize the request to lowercase to avoid case sensitivity + # This works with Lightning but might not work with other methods + request = melt_quote.request.lower() invoice_obj = bolt11.decode(melt_quote.request) assert invoice_obj.amount_msat, "invoice has no amount." @@ -476,7 +483,7 @@ async def melt_quote( # so that we can handle the transaction internally without lightning # and respond with zero fees mint_quote = await self.crud.get_mint_quote_by_request( - request=melt_quote.request, db=self.db + request=request, db=self.db ) if mint_quote: # internal transaction, validate and return amount from @@ -485,9 +492,7 @@ async def melt_quote( Amount(unit, mint_quote.amount).to(Unit.msat).amount == invoice_obj.amount_msat ), "amounts do not match" - assert ( - melt_quote.request == mint_quote.request - ), "bolt11 requests do not match" + assert request == mint_quote.request, "bolt11 requests do not match" assert mint_quote.unit == melt_quote.unit, "units do not match" assert mint_quote.method == method.name, "methods do not match" assert not mint_quote.paid, "mint quote already paid" @@ -499,14 +504,12 @@ async def melt_quote( fee=Amount(unit=Unit.msat, amount=0), ) logger.info( - f"Issuing internal melt quote: {melt_quote.request} ->" + f"Issuing internal melt quote: {request} ->" f" {mint_quote.quote} ({mint_quote.amount} {mint_quote.unit})" ) else: # not internal, get quote by backend - payment_quote = await self.backends[method][unit].get_payment_quote( - melt_quote.request - ) + payment_quote = await self.backends[method][unit].get_payment_quote(request) assert payment_quote.checking_id, "quote has no checking id" expiry = None @@ -516,9 +519,9 @@ async def melt_quote( quote = MeltQuote( quote=random_hash(), method=method.name, - request=melt_quote.request, + request=request, checking_id=payment_quote.checking_id, - unit=melt_quote.unit, + unit=unit.name, amount=payment_quote.amount.to(unit).amount, paid=False, fee_reserve=payment_quote.fee.to(unit).amount, From e93837f2ea6322c986661ffd4ade22cf9bc437e6 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 17 Mar 2024 15:42:30 +0100 Subject: [PATCH 09/16] Add PyPi pipeline (#482) * add pypi pipeline * build and publish * prepare inputs * matrix strategy * matrix * install only whl * try upload should error * try token * retry token * use token directly * no poetry config * try again * upload on release * bump --- .github/workflows/pypi.yaml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/pypi.yaml diff --git a/.github/workflows/pypi.yaml b/.github/workflows/pypi.yaml new file mode 100644 index 00000000..15b8c57c --- /dev/null +++ b/.github/workflows/pypi.yaml @@ -0,0 +1,35 @@ +name: Pip package + +on: + push: + release: + types: [published] + +jobs: + build-and-push: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10"] + poetry-version: ["1.7.1"] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: ./.github/actions/prepare + with: + python-version: ${{ matrix.python-version }} + poetry-version: ${{ matrix.poetry-version }} + + - name: Build package + run: | + poetry build + + - name: Install package + run: | + pip install --upgrade dist/*.whl + + - name: Upload to PyPI on release + if: github.event_name == 'release' + run: | + poetry publish -u __token__ -p ${{ secrets.PYPI_API_TOKEN }} From df2c81ee8919efb63fcb4c4eea2b6ae02278a952 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:53:47 +0100 Subject: [PATCH 10/16] NUTs: info endpoint method settings (#487) * adjust info endpoint settings * respect MINT_MAX_PEG_IN MINT_MAX_PEG_OUT settings --- cashu/core/base.py | 7 +++++++ cashu/mint/router.py | 27 ++++++++++++++++++++------- tests/test_mint_api.py | 8 ++++++++ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index d0a3eff3..c2694ec9 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -302,6 +302,13 @@ def from_row(cls, row: Row): # ------- API: INFO ------- +class MintMeltMethodSetting(BaseModel): + method: str + unit: str + min_amount: Optional[int] = None + max_amount: Optional[int] = None + + class GetInfoResponse(BaseModel): name: Optional[str] = None pubkey: Optional[str] = None diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 58af4203..50c41c7f 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -9,6 +9,7 @@ KeysetsResponseKeyset, KeysResponse, KeysResponseKeyset, + MintMeltMethodSetting, PostCheckStateRequest, PostCheckStateResponse, PostMeltQuoteRequest, @@ -41,19 +42,31 @@ async def info() -> GetInfoResponse: logger.trace("> GET /v1/info") # determine all method-unit pairs - method_unit_pairs: List[List[str]] = [] - for method, unit_dict in ledger.backends.items(): - for unit in unit_dict.keys(): - method_unit_pairs.append([method.name, unit.name]) + method_settings: Dict[int, List[MintMeltMethodSetting]] = {} + for nut in [4, 5]: + method_settings[nut] = [] + for method, unit_dict in ledger.backends.items(): + for unit in unit_dict.keys(): + setting = MintMeltMethodSetting(method=method.name, unit=unit.name) + + if nut == 4 and settings.mint_max_peg_in: + setting.max_amount = settings.mint_max_peg_in + setting.min_amount = 0 + elif nut == 5 and settings.mint_max_peg_out: + setting.max_amount = settings.mint_max_peg_out + setting.min_amount = 0 + + method_settings[nut].append(setting) + supported_dict = dict(supported=True) mint_features: Dict[int, Dict[str, Any]] = { 4: dict( - methods=method_unit_pairs, - disabled=False, + methods=method_settings[4], + disabled=settings.mint_peg_out_only, ), 5: dict( - methods=method_unit_pairs, + methods=method_settings[5], disabled=False, ), 7: supported_dict, diff --git a/tests/test_mint_api.py b/tests/test_mint_api.py index b5e5b64b..fab370a6 100644 --- a/tests/test_mint_api.py +++ b/tests/test_mint_api.py @@ -4,6 +4,8 @@ import pytest_asyncio from cashu.core.base import ( + GetInfoResponse, + MintMeltMethodSetting, PostCheckStateRequest, PostCheckStateResponse, PostMintRequest, @@ -40,6 +42,12 @@ async def test_info(ledger: Ledger): assert response.status_code == 200, f"{response.url} {response.status_code}" assert ledger.pubkey assert response.json()["pubkey"] == ledger.pubkey.serialize().hex() + info = GetInfoResponse(**response.json()) + assert info.nuts + assert info.nuts[4]["disabled"] is False + setting = MintMeltMethodSetting.parse_obj(info.nuts[4]["methods"][0]) + assert setting.method == "bolt11" + assert setting.unit == "sat" @pytest.mark.asyncio From f4621345f38b62ac1a65deadb57c4462bf5c26d0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 21 Mar 2024 22:59:47 +0100 Subject: [PATCH 11/16] Use `PostRestoreRequest` for all restore operations (#483) * use PostRestoreRequest for all restore operations * refactor: unit method verification --- cashu/core/base.py | 6 +++++ cashu/mint/ledger.py | 38 +++++++++++++++++++------------ cashu/mint/protocols.py | 8 +++---- cashu/mint/router.py | 5 ++-- cashu/mint/router_deprecated.py | 3 ++- cashu/mint/verification.py | 34 ++++++++++++++++++++++----- tests/test_mint_api.py | 4 ++-- tests/test_mint_api_deprecated.py | 4 ++-- 8 files changed, 71 insertions(+), 31 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index c2694ec9..072d2a8e 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -535,6 +535,12 @@ class CheckFeesResponse_deprecated(BaseModel): # ------- API: RESTORE ------- +class PostRestoreRequest(BaseModel): + outputs: List[BlindedMessage] = Field( + ..., max_items=settings.mint_max_request_length + ) + + class PostRestoreResponse(BaseModel): outputs: List[BlindedMessage] = [] signatures: List[BlindedSignature] = [] diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 3bb0cb8a..cff10b4d 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -325,8 +325,11 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: ) if settings.mint_peg_out_only: raise NotAllowedError("Mint does not allow minting new tokens.") - unit = Unit[quote_request.unit] - method = Method.bolt11 + + unit, method = self._verify_and_get_unit_method( + quote_request.unit, Method.bolt11.name + ) + if settings.mint_max_balance: balance = await self.get_balance() if balance + quote_request.amount > settings.mint_max_balance: @@ -387,10 +390,10 @@ async def get_mint_quote(self, quote_id: str) -> MintQuote: MintQuote: Mint quote object. """ quote = await self.crud.get_mint_quote(quote_id=quote_id, db=self.db) - assert quote, "quote not found" - assert quote.method == Method.bolt11.name, "only bolt11 supported" - unit = Unit[quote.unit] - method = Method[quote.method] + if not quote: + raise Exception("quote not found") + + unit, method = self._verify_and_get_unit_method(quote.unit, quote.method) if not quote.paid: assert quote.checking_id, "quote has no checking id" @@ -471,8 +474,10 @@ async def melt_quote( Returns: PostMeltQuoteResponse: Melt quote response. """ - unit = Unit[melt_quote.unit] - method = Method.bolt11 + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, Method.bolt11.name + ) + # NOTE: we normalize the request to lowercase to avoid case sensitivity # This works with Lightning but might not work with other methods request = melt_quote.request.lower() @@ -557,10 +562,12 @@ async def get_melt_quote( MeltQuote: Melt quote object. """ melt_quote = await self.crud.get_melt_quote(quote_id=quote_id, db=self.db) - assert melt_quote, "quote not found" - assert melt_quote.method == Method.bolt11.name, "only bolt11 supported" - unit = Unit[melt_quote.unit] - method = Method[melt_quote.method] + if not melt_quote: + raise Exception("quote not found") + + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, melt_quote.method + ) # we only check the state with the backend if there is no associated internal # mint quote for this melt quote @@ -664,8 +671,11 @@ async def melt( """ # get melt quote and check if it was already paid melt_quote = await self.get_melt_quote(quote_id=quote) - method = Method[melt_quote.method] - unit = Unit[melt_quote.unit] + + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, melt_quote.method + ) + assert not melt_quote.paid, "melt quote already paid" # make sure that the outputs (for fee return) are in the same unit as the quote diff --git a/cashu/mint/protocols.py b/cashu/mint/protocols.py index 47bf618e..04d24c0c 100644 --- a/cashu/mint/protocols.py +++ b/cashu/mint/protocols.py @@ -1,6 +1,6 @@ -from typing import Dict, Protocol +from typing import Dict, Mapping, Protocol -from ..core.base import MintKeyset, Unit +from ..core.base import Method, MintKeyset, Unit from ..core.db import Database from ..lightning.base import LightningBackend from ..mint.crud import LedgerCrud @@ -11,8 +11,8 @@ class SupportsKeysets(Protocol): keysets: Dict[str, MintKeyset] -class SupportLightning(Protocol): - lightning: Dict[Unit, LightningBackend] +class SupportsBackends(Protocol): + backends: Mapping[Method, Mapping[Unit, LightningBackend]] = {} class SupportsDb(Protocol): diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 50c41c7f..19e6f46b 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -20,6 +20,7 @@ PostMintQuoteResponse, PostMintRequest, PostMintResponse, + PostRestoreRequest, PostRestoreResponse, PostSplitRequest, PostSplitResponse, @@ -358,14 +359,14 @@ async def check_state( @router.post( "/v1/restore", name="Restore", - summary="Restores a blinded signature from a secret", + summary="Restores blind signature for a set of outputs.", response_model=PostRestoreResponse, response_description=( "Two lists with the first being the list of the provided outputs that " "have an associated blinded signature which is given in the second list." ), ) -async def restore(payload: PostMintRequest) -> PostRestoreResponse: +async def restore(payload: PostRestoreRequest) -> PostRestoreResponse: assert payload.outputs, Exception("no outputs provided.") outputs, signatures = await ledger.restore(payload.outputs) return PostRestoreResponse(outputs=outputs, signatures=signatures) diff --git a/cashu/mint/router_deprecated.py b/cashu/mint/router_deprecated.py index f71a8a34..e1901d7b 100644 --- a/cashu/mint/router_deprecated.py +++ b/cashu/mint/router_deprecated.py @@ -19,6 +19,7 @@ PostMintQuoteRequest, PostMintRequest_deprecated, PostMintResponse_deprecated, + PostRestoreRequest, PostRestoreResponse, PostSplitRequest_Deprecated, PostSplitResponse_Deprecated, @@ -357,7 +358,7 @@ async def check_spendable_deprecated( ), deprecated=True, ) -async def restore(payload: PostMintRequest_deprecated) -> PostRestoreResponse: +async def restore(payload: PostRestoreRequest) -> PostRestoreResponse: assert payload.outputs, Exception("no outputs provided.") outputs, promises = await ledger.restore(payload.outputs) return PostRestoreResponse(outputs=outputs, signatures=promises) diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index 754c8b1c..de38dca3 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -1,12 +1,14 @@ -from typing import Dict, List, Literal, Optional, Union +from typing import Dict, List, Literal, Optional, Tuple, Union from loguru import logger from ..core.base import ( BlindedMessage, BlindedSignature, + Method, MintKeyset, Proof, + Unit, ) from ..core.crypto import b_dhke from ..core.crypto.secp import PublicKey @@ -19,12 +21,15 @@ TransactionError, ) from ..core.settings import settings +from ..lightning.base import LightningBackend from ..mint.crud import LedgerCrud from .conditions import LedgerSpendingConditions -from .protocols import SupportsDb, SupportsKeysets +from .protocols import SupportsBackends, SupportsDb, SupportsKeysets -class LedgerVerification(LedgerSpendingConditions, SupportsKeysets, SupportsDb): +class LedgerVerification( + LedgerSpendingConditions, SupportsKeysets, SupportsDb, SupportsBackends +): """Verification functions for the ledger.""" keyset: MintKeyset @@ -32,6 +37,7 @@ class LedgerVerification(LedgerSpendingConditions, SupportsKeysets, SupportsDb): spent_proofs: Dict[str, Proof] crud: LedgerCrud db: Database + lightning: Dict[Unit, LightningBackend] async def verify_inputs_and_outputs( self, *, proofs: List[Proof], outputs: Optional[List[BlindedMessage]] = None @@ -240,6 +246,22 @@ def _verify_equation_balanced( """ sum_inputs = sum(self._verify_amount(p.amount) for p in proofs) sum_outputs = sum(self._verify_amount(p.amount) for p in outs) - assert sum_outputs - sum_inputs == 0, TransactionError( - "inputs do not have same amount as outputs." - ) + if not sum_outputs - sum_inputs == 0: + raise TransactionError("inputs do not have same amount as outputs.") + + def _verify_and_get_unit_method( + self, unit_str: str, method_str: str + ) -> Tuple[Unit, Method]: + """Verify that the unit is supported by the ledger.""" + method = Method[method_str] + unit = Unit[unit_str] + + if not any([unit == k.unit for k in self.keysets.values()]): + raise NotAllowedError(f"unit '{unit.name}' not supported in any keyset.") + + if not self.backends.get(method) or unit not in self.backends[method]: + raise NotAllowedError( + f"no support for method '{method.name}' with unit '{unit.name}'." + ) + + return unit, method diff --git a/tests/test_mint_api.py b/tests/test_mint_api.py index fab370a6..3a7acbd3 100644 --- a/tests/test_mint_api.py +++ b/tests/test_mint_api.py @@ -8,7 +8,7 @@ MintMeltMethodSetting, PostCheckStateRequest, PostCheckStateResponse, - PostMintRequest, + PostRestoreRequest, PostRestoreResponse, SpentState, ) @@ -430,7 +430,7 @@ async def test_api_restore(ledger: Ledger, wallet: Wallet): ) outputs, rs = wallet._construct_outputs([64], secrets, rs) - payload = PostMintRequest(outputs=outputs, quote="placeholder") + payload = PostRestoreRequest(outputs=outputs) response = httpx.post( f"{BASE_URL}/v1/restore", json=payload.dict(), diff --git a/tests/test_mint_api_deprecated.py b/tests/test_mint_api_deprecated.py index 478b5ba4..fc40589c 100644 --- a/tests/test_mint_api_deprecated.py +++ b/tests/test_mint_api_deprecated.py @@ -5,7 +5,7 @@ from cashu.core.base import ( CheckSpendableRequest_deprecated, CheckSpendableResponse_deprecated, - PostMintRequest, + PostRestoreRequest, PostRestoreResponse, Proof, ) @@ -340,7 +340,7 @@ async def test_api_restore(ledger: Ledger, wallet: Wallet): ) outputs, rs = wallet._construct_outputs([64], secrets, rs) - payload = PostMintRequest(outputs=outputs, quote="placeholder") + payload = PostRestoreRequest(outputs=outputs) response = httpx.post( f"{BASE_URL}/restore", json=payload.dict(), From 3ba1e81fcb7cc55a377a6d8b7586d04c20853748 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 22 Mar 2024 12:11:40 +0100 Subject: [PATCH 12/16] Mint: Fakewallet support for USD (#488) * fakewallet usd wip * FakeWallet support for USD * fix api return for receive * use MINT_BACKEND_BOLT11_SAT everywhere --- .env.example | 4 +-- .github/workflows/tests.yml | 2 +- Makefile | 2 +- README.md | 15 +++++++--- cashu/core/settings.py | 10 ++++++- cashu/lightning/__init__.py | 4 +-- cashu/lightning/base.py | 9 ++++-- cashu/lightning/blink.py | 10 +++++-- cashu/lightning/corelightningrest.py | 7 +++-- cashu/lightning/fake.py | 39 ++++++++++++++++++++++---- cashu/lightning/lnbits.py | 7 +++-- cashu/lightning/lndrest.py | 7 +++-- cashu/lightning/strike.py | 6 ++-- cashu/mint/ledger.py | 22 +++++++-------- cashu/mint/startup.py | 33 ++++++++++++---------- cashu/wallet/api/router.py | 41 +++++++++++++++------------- cashu/wallet/cli/cli.py | 25 +++++++++-------- cashu/wallet/cli/cli_helpers.py | 3 +- cashu/wallet/helpers.py | 11 +++++--- docker-compose.yaml | 2 +- tests/helpers.py | 2 +- 21 files changed, 168 insertions(+), 93 deletions(-) diff --git a/.env.example b/.env.example index d8e1b7cd..76641906 100644 --- a/.env.example +++ b/.env.example @@ -55,9 +55,9 @@ MINT_DERIVATION_PATH="m/0'/0'/0'" MINT_DATABASE=data/mint -# Lightning +# Funding source backends # Supported: FakeWallet, LndRestWallet, CoreLightningRestWallet, BlinkWallet, LNbitsWallet, StrikeUSDWallet -MINT_LIGHTNING_BACKEND=FakeWallet +MINT_BACKEND_BOLT11_SAT=FakeWallet # for use with LNbitsWallet MINT_LNBITS_ENDPOINT=https://legend.lnbits.com diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cf1a176b..42069702 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -49,7 +49,7 @@ jobs: poetry-version: ${{ inputs.poetry-version }} - name: Run tests env: - MINT_LIGHTNING_BACKEND: FakeWallet + MINT_BACKEND_BOLT11_SAT: FakeWallet WALLET_NAME: test_wallet MINT_HOST: localhost MINT_PORT: 3337 diff --git a/Makefile b/Makefile index f6cb065b..3dc8c347 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ test: test-lndrest: PYTHONUNBUFFERED=1 \ DEBUG=true \ - MINT_LIGHTNING_BACKEND=LndRestWallet \ + MINT_BACKEND_BOLT11_SAT=LndRestWallet \ MINT_LND_REST_ENDPOINT=https://localhost:8081/ \ MINT_LND_REST_CERT=../cashu-regtest-enviroment/data/lnd-3/tls.cert \ MINT_LND_REST_MACAROON=../cashu-regtest-enviroment/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon \ diff --git a/README.md b/README.md index fb6aa6b4..9a9a2b0f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ *Disclaimer: The author is NOT a cryptographer and this work has not been reviewed. This means that there is very likely a fatal flaw somewhere. Cashu is still experimental and not production-ready.* -Cashu is an Ecash implementation based on David Wagner's variant of Chaumian blinding ([protocol specs](https://github.com/cashubtc/nuts)). Token logic based on [minicash](https://github.com/phyro/minicash) ([description](https://gist.github.com/phyro/935badc682057f418842c72961cf096c)) which implements a [Blind Diffie-Hellman Key Exchange](https://cypherpunks.venona.com/date/1996/03/msg01848.html) scheme written down [here](https://gist.github.com/RubenSomsen/be7a4760dd4596d06963d67baf140406). The database mechanics in Cashu Nutshell and the Lightning backend uses parts from [LNbits](https://github.com/lnbits/lnbits-legend). +Cashu is an Ecash implementation based on David Wagner's variant of Chaumian blinding ([protocol specs](https://github.com/cashubtc/nuts)). Token logic based on [minicash](https://github.com/phyro/minicash) ([description](https://gist.github.com/phyro/935badc682057f418842c72961cf096c)) which implements a [Blind Diffie-Hellman Key Exchange](https://cypherpunks.venona.com/date/1996/03/msg01848.html) scheme written down [here](https://gist.github.com/RubenSomsen/be7a4760dd4596d06963d67baf140406).

Cashu protocol · @@ -169,12 +169,19 @@ You can find the API docs at [http://localhost:4448/docs](http://localhost:4448/ # Running a mint This command runs the mint on your local computer. Skip this step if you want to use the [public test mint](#test-instance) instead. -Before you can run your own mint, make sure to enable a Lightning backend in `MINT_LIGHTNING_BACKEND` and set `MINT_PRIVATE_KEY` in your `.env` file. +## Docker + +``` +docker run -d -p 3338:3338 --name nutshell -e MINT_BACKEND_BOLT11_SAT=FakeWallet -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY cashubtc/nutshell:0.15.2 poetry run mint +``` + +## From this repository +Before you can run your own mint, make sure to enable a Lightning backend in `MINT_BACKEND_BOLT11_SAT` and set `MINT_PRIVATE_KEY` in your `.env` file. ```bash poetry run mint ``` -For testing, you can use Nutshell without a Lightning backend by setting `MINT_LIGHTNING_BACKEND=FakeWallet` in the `.env` file. +For testing, you can use Nutshell without a Lightning backend by setting `MINT_BACKEND_BOLT11_SAT=FakeWallet` in the `.env` file. # Running tests @@ -185,7 +192,7 @@ poetry install --with dev Then, make sure to set up your mint's `.env` file to use a fake Lightning backend and disable Tor: ```bash -MINT_LIGHTNING_BACKEND=FakeWallet +MINT_BACKEND_BOLT11_SAT=FakeWallet TOR=FALSE ``` You can run the tests with diff --git a/cashu/core/settings.py b/cashu/core/settings.py index a27fddac..f1f1946a 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -55,7 +55,11 @@ class MintSettings(CashuSettings): mint_derivation_path_list: List[str] = Field(default=[]) mint_listen_host: str = Field(default="127.0.0.1") mint_listen_port: int = Field(default=3338) - mint_lightning_backend: str = Field(default="LNbitsWallet") + + mint_lightning_backend: str = Field(default="") # deprecated + mint_backend_bolt11_sat: str = Field(default="") + mint_backend_bolt11_usd: str = Field(default="") + mint_database: str = Field(default="data/mint") mint_test_database: str = Field(default="test_data/test_mint") mint_peg_out_only: bool = Field( @@ -204,5 +208,9 @@ def startup_settings_tasks(): if settings.socks_host and settings.socks_port: settings.socks_proxy = f"socks5://{settings.socks_host}:{settings.socks_port}" + # backwards compatibility: set mint_backend_bolt11_sat from mint_lightning_backend + if settings.mint_lightning_backend: + settings.mint_backend_bolt11_sat = settings.mint_lightning_backend + startup_settings_tasks() diff --git a/cashu/lightning/__init__.py b/cashu/lightning/__init__.py index 89e46188..521eb497 100644 --- a/cashu/lightning/__init__.py +++ b/cashu/lightning/__init__.py @@ -7,5 +7,5 @@ from .lndrest import LndRestWallet # noqa: F401 from .strike import StrikeUSDWallet # noqa: F401 -if settings.mint_lightning_backend is None: - raise Exception("MINT_LIGHTNING_BACKEND not configured") +if settings.mint_backend_bolt11_sat is None or settings.mint_backend_bolt11_usd is None: + raise Exception("MINT_BACKEND_BOLT11_SAT or MINT_BACKEND_BOLT11_USD not set") diff --git a/cashu/lightning/base.py b/cashu/lightning/base.py index 089a0290..083554fd 100644 --- a/cashu/lightning/base.py +++ b/cashu/lightning/base.py @@ -62,12 +62,17 @@ def __str__(self) -> str: class LightningBackend(ABC): - units: set[Unit] + supported_units: set[Unit] + unit: Unit def assert_unit_supported(self, unit: Unit): - if unit not in self.units: + if unit not in self.supported_units: raise Unsupported(f"Unit {unit} is not supported") + @abstractmethod + def __init__(self, unit: Unit, **kwargs): + pass + @abstractmethod def status(self) -> Coroutine[None, None, StatusResponse]: pass diff --git a/cashu/lightning/blink.py b/cashu/lightning/blink.py index 5379120b..0e12fb1b 100644 --- a/cashu/lightning/blink.py +++ b/cashu/lightning/blink.py @@ -36,7 +36,6 @@ class BlinkWallet(LightningBackend): Create API Key at: https://dashboard.blink.sv/ """ - units = set([Unit.sat, Unit.usd]) wallet_ids: Dict[Unit, str] = {} endpoint = "https://api.blink.sv/graphql" invoice_statuses = {"PENDING": None, "PAID": True, "EXPIRED": False} @@ -47,7 +46,12 @@ class BlinkWallet(LightningBackend): } payment_statuses = {"SUCCESS": True, "PENDING": None, "FAILURE": False} - def __init__(self): + supported_units = set([Unit.sat, Unit.msat]) + unit = Unit.sat + + def __init__(self, unit: Unit = Unit.sat, **kwargs): + self.assert_unit_supported(unit) + self.unit = unit assert settings.mint_blink_key, "MINT_BLINK_KEY not set" self.client = httpx.AsyncClient( verify=not settings.debug, @@ -297,7 +301,7 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: ... on SettlementViaLn { preImage } - } + } } } } diff --git a/cashu/lightning/corelightningrest.py b/cashu/lightning/corelightningrest.py index 066a1b89..dff61a41 100644 --- a/cashu/lightning/corelightningrest.py +++ b/cashu/lightning/corelightningrest.py @@ -26,9 +26,12 @@ class CoreLightningRestWallet(LightningBackend): - units = set([Unit.sat, Unit.msat]) + supported_units = set([Unit.sat, Unit.msat]) + unit = Unit.sat - def __init__(self): + def __init__(self, unit: Unit = Unit.sat, **kwargs): + self.assert_unit_supported(unit) + self.unit = unit macaroon = settings.mint_corelightning_rest_macaroon assert macaroon, "missing cln-rest macaroon" diff --git a/cashu/lightning/fake.py b/cashu/lightning/fake.py index 8da840ee..111eae1b 100644 --- a/cashu/lightning/fake.py +++ b/cashu/lightning/fake.py @@ -1,5 +1,6 @@ import asyncio import hashlib +import math import random from datetime import datetime from os import urandom @@ -28,7 +29,7 @@ class FakeWallet(LightningBackend): - units = set([Unit.sat, Unit.msat]) + fake_btc_price = 1e8 / 1337 queue: asyncio.Queue[Bolt11] = asyncio.Queue(0) payment_secrets: Dict[str, str] = dict() paid_invoices: Set[str] = set() @@ -41,6 +42,13 @@ class FakeWallet(LightningBackend): 32, ).hex() + supported_units = set([Unit.sat, Unit.msat, Unit.usd]) + unit = Unit.sat + + def __init__(self, unit: Unit = Unit.sat, **kwargs): + self.assert_unit_supported(unit) + self.unit = unit + async def status(self) -> StatusResponse: return StatusResponse(error_message=None, balance=1337) @@ -80,9 +88,19 @@ async def create_invoice( self.payment_secrets[payment_hash] = secret + amount_msat = 0 + if self.unit == Unit.sat: + amount_msat = MilliSatoshi(amount.to(Unit.msat, round="up").amount) + elif self.unit == Unit.usd: + amount_msat = MilliSatoshi( + math.ceil(amount.amount / self.fake_btc_price * 1e9) + ) + else: + raise NotImplementedError() + bolt11 = Bolt11( currency="bc", - amount_msat=MilliSatoshi(amount.to(Unit.msat, round="up").amount), + amount_msat=amount_msat, date=int(datetime.now().timestamp()), tags=tags, ) @@ -137,10 +155,19 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: invoice_obj = decode(bolt11) assert invoice_obj.amount_msat, "invoice has no amount." - amount_msat = int(invoice_obj.amount_msat) - fees_msat = fee_reserve(amount_msat) - fees = Amount(unit=Unit.msat, amount=fees_msat) - amount = Amount(unit=Unit.msat, amount=amount_msat) + + if self.unit == Unit.sat: + amount_msat = int(invoice_obj.amount_msat) + fees_msat = fee_reserve(amount_msat) + fees = Amount(unit=Unit.msat, amount=fees_msat) + amount = Amount(unit=Unit.msat, amount=amount_msat) + elif self.unit == Unit.usd: + amount_usd = math.ceil(invoice_obj.amount_msat / 1e9 * self.fake_btc_price) + amount = Amount(unit=Unit.usd, amount=amount_usd) + fees = Amount(unit=Unit.usd, amount=1) + else: + raise NotImplementedError() + return PaymentQuoteResponse( checking_id=invoice_obj.payment_hash, fee=fees, amount=amount ) diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py index 3896a4e1..916cb710 100644 --- a/cashu/lightning/lnbits.py +++ b/cashu/lightning/lnbits.py @@ -22,9 +22,12 @@ class LNbitsWallet(LightningBackend): """https://github.com/lnbits/lnbits""" - units = set([Unit.sat]) + supported_units = set([Unit.sat]) + unit = Unit.sat - def __init__(self): + def __init__(self, unit: Unit = Unit.sat, **kwargs): + self.assert_unit_supported(unit) + self.unit = unit self.endpoint = settings.mint_lnbits_endpoint self.client = httpx.AsyncClient( verify=not settings.debug, diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index 3aa61a0a..dd47ea0e 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -27,9 +27,12 @@ class LndRestWallet(LightningBackend): """https://api.lightning.community/rest/index.html#lnd-rest-api-reference""" - units = set([Unit.sat, Unit.msat]) + supported_units = set([Unit.sat, Unit.msat]) + unit = Unit.sat - def __init__(self): + def __init__(self, unit: Unit = Unit.sat, **kwargs): + self.assert_unit_supported(unit) + self.unit = unit endpoint = settings.mint_lnd_rest_endpoint cert = settings.mint_lnd_rest_cert diff --git a/cashu/lightning/strike.py b/cashu/lightning/strike.py index c755ad17..5abd5fb6 100644 --- a/cashu/lightning/strike.py +++ b/cashu/lightning/strike.py @@ -19,9 +19,11 @@ class StrikeUSDWallet(LightningBackend): """https://github.com/lnbits/lnbits""" - units = [Unit.usd] + supported_units = [Unit.usd] - def __init__(self): + def __init__(self, unit: Unit = Unit.usd, **kwargs): + self.assert_unit_supported(unit) + self.unit = unit self.endpoint = "https://api.strike.me" # bearer auth with settings.mint_strike_key diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index cff10b4d..d994448a 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1,6 +1,5 @@ import asyncio import copy -import math import time from typing import Dict, List, Mapping, Optional, Tuple @@ -493,10 +492,10 @@ async def melt_quote( if mint_quote: # internal transaction, validate and return amount from # associated mint quote and demand zero fees - assert ( - Amount(unit, mint_quote.amount).to(Unit.msat).amount - == invoice_obj.amount_msat - ), "amounts do not match" + # assert ( + # Amount(unit, mint_quote.amount).to(Unit.msat).amount + # == invoice_obj.amount_msat + # ), "amounts do not match" assert request == mint_quote.request, "bolt11 requests do not match" assert mint_quote.unit == melt_quote.unit, "units do not match" assert mint_quote.method == method.name, "methods do not match" @@ -506,7 +505,7 @@ async def melt_quote( payment_quote = PaymentQuoteResponse( checking_id=mint_quote.checking_id, amount=Amount(unit, mint_quote.amount), - fee=Amount(unit=Unit.msat, amount=0), + fee=Amount(unit, amount=0), ) logger.info( f"Issuing internal melt quote: {request} ->" @@ -622,11 +621,12 @@ async def melt_mint_settle_internally(self, melt_quote: MeltQuote) -> MeltQuote: bolt11_request = melt_quote.request invoice_obj = bolt11.decode(bolt11_request) assert invoice_obj.amount_msat, "invoice has no amount." - invoice_amount_sat = math.ceil(invoice_obj.amount_msat / 1000) - assert ( - Amount(Unit[melt_quote.unit], mint_quote.amount).to(Unit.sat).amount - == invoice_amount_sat - ), "amounts do not match" + # invoice_amount_sat = math.ceil(invoice_obj.amount_msat / 1000) + # assert ( + # Amount(Unit[melt_quote.unit], mint_quote.amount).to(Unit.sat).amount + # == invoice_amount_sat + # ), "amounts do not match" + assert mint_quote.amount == melt_quote.amount, "amounts do not match" assert bolt11_request == mint_quote.request, "bolt11 requests do not match" assert mint_quote.unit == melt_quote.unit, "units do not match" assert mint_quote.method == melt_quote.method, "methods do not match" diff --git a/cashu/mint/startup.py b/cashu/mint/startup.py index f094f97b..94d16e61 100644 --- a/cashu/mint/startup.py +++ b/cashu/mint/startup.py @@ -3,6 +3,7 @@ import asyncio import importlib +from typing import Dict from loguru import logger @@ -10,6 +11,7 @@ from ..core.db import Database from ..core.migrations import migrate_databases from ..core.settings import settings +from ..lightning.base import LightningBackend from ..mint import migrations from ..mint.crud import LedgerCrudSqlite from ..mint.ledger import Ledger @@ -31,23 +33,24 @@ logger.debug(f"{key}: {value}") wallets_module = importlib.import_module("cashu.lightning") -lightning_backend = getattr(wallets_module, settings.mint_lightning_backend)() -assert settings.mint_private_key is not None, "No mint private key set." +backends: Dict[Method, Dict[Unit, LightningBackend]] = {} +if settings.mint_backend_bolt11_sat: + backend_bolt11_sat = getattr(wallets_module, settings.mint_backend_bolt11_sat)( + unit=Unit.sat + ) + backends.setdefault(Method.bolt11, {})[Unit.sat] = backend_bolt11_sat +if settings.mint_backend_bolt11_usd: + backend_bolt11_usd = getattr(wallets_module, settings.mint_backend_bolt11_usd)( + unit=Unit.usd + ) + backends.setdefault(Method.bolt11, {})[Unit.usd] = backend_bolt11_usd +if not backends: + raise Exception("No backends are set.") + +if not settings.mint_private_key: + raise Exception("No mint private key is set.") -# strike_backend = getattr(wallets_module, "StrikeUSDWallet")() -# backends = { -# Method.bolt11: {Unit.sat: lightning_backend, Unit.usd: strike_backend}, -# } -# backends = { -# Method.bolt11: {Unit.sat: lightning_backend, Unit.msat: lightning_backend}, -# } -# backends = { -# Method.bolt11: {Unit.sat: lightning_backend, Unit.msat: lightning_backend, -# } -backends = { - Method.bolt11: {Unit.sat: lightning_backend}, -} ledger = Ledger( db=Database("mint", settings.mint_database), seed=settings.mint_private_key, diff --git a/cashu/wallet/api/router.py b/cashu/wallet/api/router.py index 611bfbfa..bf8a0576 100644 --- a/cashu/wallet/api/router.py +++ b/cashu/wallet/api/router.py @@ -265,10 +265,9 @@ async def receive_command( if token: tokenObj: TokenV3 = deserialize_token_from_string(token) await verify_mints(wallet, tokenObj) - balance = await receive(wallet, tokenObj) + await receive(wallet, tokenObj) elif nostr: await receive_nostr(wallet) - balance = wallet.available_balance elif all: reserved_proofs = await get_reserved_proofs(wallet.db) balance = None @@ -278,10 +277,10 @@ async def receive_command( token = await wallet.serialize_proofs(proofs) tokenObj = deserialize_token_from_string(token) await verify_mints(wallet, tokenObj) - balance = await receive(wallet, tokenObj) + await receive(wallet, tokenObj) else: raise Exception("enter token or use either flag --nostr or --all.") - assert balance + balance = wallet.available_balance return ReceiveResponse(initial_balance=initial_balance, balance=balance) @@ -359,15 +358,17 @@ async def pending( reserved_date = datetime.utcfromtimestamp( int(grouped_proofs[0].time_reserved) # type: ignore ).strftime("%Y-%m-%d %H:%M:%S") - result.update({ - f"{i}": { - "amount": sum_proofs(grouped_proofs), - "time": reserved_date, - "ID": key, - "token": token, - "mint": mint, + result.update( + { + f"{i}": { + "amount": sum_proofs(grouped_proofs), + "time": reserved_date, + "ID": key, + "token": token, + "mint": mint, + } } - }) + ) return PendingResponse(pending_token=result) @@ -412,14 +413,16 @@ async def wallets(): if w == wallet.name: active_wallet = True if active_wallet: - result.update({ - f"{w}": { - "balance": sum_proofs(wallet.proofs), - "available": sum_proofs([ - p for p in wallet.proofs if not p.reserved - ]), + result.update( + { + f"{w}": { + "balance": sum_proofs(wallet.proofs), + "available": sum_proofs( + [p for p in wallet.proofs if not p.reserved] + ), + } } - }) + ) except Exception: pass return WalletsResponse(wallets=result) diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 10251477..6332d5da 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -184,7 +184,7 @@ async def cli(ctx: Context, host: str, walletname: str, unit: str, tests: bool): async def pay(ctx: Context, invoice: str, yes: bool): wallet: Wallet = ctx.obj["WALLET"] await wallet.load_mint() - print_balance(ctx) + await print_balance(ctx) quote = await wallet.get_pay_amount_with_fees(invoice) logger.debug(f"Quote: {quote}") total_amount = quote.amount + quote.fee_reserve @@ -219,7 +219,7 @@ async def pay(ctx: Context, invoice: str, yes: bool): print(f" (Preimage: {melt_response.payment_preimage}).") else: print(".") - print_balance(ctx) + await print_balance(ctx) @cli.command("invoice", help="Create Lighting invoice.") @@ -242,11 +242,12 @@ async def pay(ctx: Context, invoice: str, yes: bool): ) @click.pass_context @coro -async def invoice(ctx: Context, amount: int, id: str, split: int, no_check: bool): +async def invoice(ctx: Context, amount: float, id: str, split: int, no_check: bool): wallet: Wallet = ctx.obj["WALLET"] await wallet.load_mint() - print_balance(ctx) + await print_balance(ctx) amount = int(amount * 100) if wallet.unit == Unit.usd else int(amount) + print(f"Requesting invoice for {wallet.unit.str(amount)} {wallet.unit}.") # in case the user wants a specific split, we create a list of amounts optional_split = None if split: @@ -305,7 +306,7 @@ async def invoice(ctx: Context, amount: int, id: str, split: int, no_check: bool elif amount and id: await wallet.mint(amount, split=optional_split, id=id) print("") - print_balance(ctx) + await print_balance(ctx) return @@ -474,7 +475,7 @@ async def send_command( await send_nostr( wallet, amount=amount, pubkey=nostr or nopt, verbose=verbose, yes=yes ) - print_balance(ctx) + await print_balance(ctx) @cli.command("receive", help="Receive tokens.") @@ -508,8 +509,9 @@ async def receive_cli( mint_url, os.path.join(settings.cashu_dir, wallet.name) ) await verify_mint(mint_wallet, mint_url) + receive_wallet = await receive(wallet, tokenObj) + ctx.obj["WALLET"] = receive_wallet - await receive(wallet, tokenObj) elif nostr: await receive_nostr(wallet) # exit on keypress @@ -530,11 +532,12 @@ async def receive_cli( mint_url, os.path.join(settings.cashu_dir, wallet.name) ) await verify_mint(mint_wallet, mint_url) - await receive(wallet, tokenObj) + receive_wallet = await receive(wallet, tokenObj) + ctx.obj["WALLET"] = receive_wallet else: print("Error: enter token or use either flag --nostr or --all.") return - print_balance(ctx) + await print_balance(ctx) @cli.command("burn", help="Burn spent tokens.") @@ -586,7 +589,7 @@ async def burn(ctx: Context, token: str, all: bool, force: bool, delete: str): for i in range(0, len(proofs), settings.proofs_batch_size) ]: await wallet.invalidate(_proofs, check_spendable=True) - print_balance(ctx) + await print_balance(ctx) @cli.command("pending", help="Show pending tokens.") @@ -865,7 +868,7 @@ async def restore(ctx: Context, to: int, batch: int): await wallet.restore_wallet_from_mnemonic(mnemonic, to=to, batch=batch) await wallet.load_proofs() - print_balance(ctx) + await print_balance(ctx) @cli.command("selfpay", help="Refresh tokens.") diff --git a/cashu/wallet/cli/cli_helpers.py b/cashu/wallet/cli/cli_helpers.py index 8de0a1c7..f5102534 100644 --- a/cashu/wallet/cli/cli_helpers.py +++ b/cashu/wallet/cli/cli_helpers.py @@ -10,8 +10,9 @@ from ...wallet.wallet import Wallet as Wallet -def print_balance(ctx: Context): +async def print_balance(ctx: Context): wallet: Wallet = ctx.obj["WALLET"] + await wallet.load_proofs(reload=True, unit=wallet.unit) print(f"Balance: {wallet.unit.str(wallet.available_balance)}") diff --git a/cashu/wallet/helpers.py b/cashu/wallet/helpers.py index 26f0d1c6..547cf6bc 100644 --- a/cashu/wallet/helpers.py +++ b/cashu/wallet/helpers.py @@ -35,7 +35,7 @@ async def list_mints(wallet: Wallet): return mints -async def redeem_TokenV3_multimint(wallet: Wallet, token: TokenV3): +async def redeem_TokenV3_multimint(wallet: Wallet, token: TokenV3) -> Wallet: """ Helper function to iterate thruogh a token with multiple mints and redeem them from these mints one keyset at a time. @@ -58,6 +58,9 @@ async def redeem_TokenV3_multimint(wallet: Wallet, token: TokenV3): _, _ = await mint_wallet.redeem(redeem_proofs) print(f"Received {mint_wallet.unit.str(sum_proofs(redeem_proofs))}") + # return the last mint_wallet + return mint_wallet + def serialize_TokenV2_to_TokenV3(tokenv2: TokenV2): """Helper function to receive legacy TokenV2 tokens. @@ -120,7 +123,7 @@ def deserialize_token_from_string(token: str) -> TokenV3: async def receive( wallet: Wallet, tokenObj: TokenV3, -): +) -> Wallet: logger.debug(f"receive: {tokenObj}") proofs = [p for t in tokenObj.token for p in t.proofs] @@ -128,7 +131,7 @@ async def receive( if includes_mint_info: # redeem tokens with new wallet instances - await redeem_TokenV3_multimint( + mint_wallet = await redeem_TokenV3_multimint( wallet, tokenObj, ) @@ -154,7 +157,7 @@ async def receive( # reload main wallet so the balance updates await wallet.load_proofs(reload=True) - return wallet.available_balance + return mint_wallet async def send( diff --git a/docker-compose.yaml b/docker-compose.yaml index 557388b7..f2695db0 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,7 +8,7 @@ services: ports: - "3338:3338" environment: - - MINT_LIGHTNING_BACKEND=FakeWallet + - MINT_BACKEND_BOLT11_SAT=FakeWallet - MINT_LISTEN_HOST=0.0.0.0 - MINT_LISTEN_PORT=3338 - MINT_PRIVATE_KEY=TEST_PRIVATE_KEY diff --git a/tests/helpers.py b/tests/helpers.py index 94fe729b..456ab21b 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -25,7 +25,7 @@ async def get_random_invoice_data(): wallets_module = importlib.import_module("cashu.lightning") -wallet_class = getattr(wallets_module, settings.mint_lightning_backend) +wallet_class = getattr(wallets_module, settings.mint_backend_bolt11_sat) WALLET = wallet_class() is_fake: bool = WALLET.__class__.__name__ == "FakeWallet" is_regtest: bool = not is_fake From b288a6d50e1b8b2a3703a9098ed4aeeabd07d232 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 23 Mar 2024 01:16:28 +0100 Subject: [PATCH 13/16] Refactor: melt quote unit validation (#489) * refactor: mint quote validation * convert amount in Lightning backend for later validation * fix blink amount return tests * retry tests * fix conftest fakewallet * fix start --- .env.example | 2 ++ .github/workflows/regtest.yml | 2 +- cashu/lightning/blink.py | 6 +++++- cashu/lightning/corelightningrest.py | 4 +++- cashu/lightning/fake.py | 4 +++- cashu/lightning/lnbits.py | 4 +++- cashu/lightning/lndrest.py | 4 +++- cashu/lightning/strike.py | 24 ------------------------ cashu/mint/ledger.py | 27 ++++++++++++++++----------- tests/conftest.py | 4 ++-- tests/test_mint_lightning_blink.py | 22 +++++++++++----------- tests/test_mint_operations.py | 2 +- 12 files changed, 50 insertions(+), 55 deletions(-) diff --git a/.env.example b/.env.example index 76641906..f0fcb4fd 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,8 @@ MINT_DATABASE=data/mint # Funding source backends # Supported: FakeWallet, LndRestWallet, CoreLightningRestWallet, BlinkWallet, LNbitsWallet, StrikeUSDWallet MINT_BACKEND_BOLT11_SAT=FakeWallet +# Only works if a usd derivation path is set +# MINT_BACKEND_BOLT11_SAT=FakeWallet # for use with LNbitsWallet MINT_LNBITS_ENDPOINT=https://legend.lnbits.com diff --git a/.github/workflows/regtest.yml b/.github/workflows/regtest.yml index 46e2d8ed..ed052a4f 100644 --- a/.github/workflows/regtest.yml +++ b/.github/workflows/regtest.yml @@ -63,7 +63,7 @@ jobs: MINT_PORT: 3337 MINT_TEST_DATABASE: ${{ inputs.mint-database }} TOR: false - MINT_LIGHTNING_BACKEND: ${{ inputs.backend-wallet-class }} + MINT_BACKEND_BOLT11_SAT: ${{ inputs.backend-wallet-class }} MINT_LNBITS_ENDPOINT: http://localhost:5001 MINT_LNBITS_KEY: d08a3313322a4514af75d488bcc27eee MINT_LND_REST_ENDPOINT: https://localhost:8081/ diff --git a/cashu/lightning/blink.py b/cashu/lightning/blink.py index 0e12fb1b..5c9f0dc4 100644 --- a/cashu/lightning/blink.py +++ b/cashu/lightning/blink.py @@ -444,7 +444,11 @@ async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: fees = Amount(unit=Unit.msat, amount=fees_msat) amount = Amount(unit=Unit.msat, amount=amount_msat) - return PaymentQuoteResponse(checking_id=bolt11, fee=fees, amount=amount) + return PaymentQuoteResponse( + checking_id=bolt11, + fee=fees.to(self.unit, round="up"), + amount=amount.to(self.unit, round="up"), + ) async def main(): diff --git a/cashu/lightning/corelightningrest.py b/cashu/lightning/corelightningrest.py index dff61a41..da36cdc1 100644 --- a/cashu/lightning/corelightningrest.py +++ b/cashu/lightning/corelightningrest.py @@ -320,5 +320,7 @@ async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: fees = Amount(unit=Unit.msat, amount=fees_msat) amount = Amount(unit=Unit.msat, amount=amount_msat) return PaymentQuoteResponse( - checking_id=invoice_obj.payment_hash, fee=fees, amount=amount + checking_id=invoice_obj.payment_hash, + fee=fees.to(self.unit, round="up"), + amount=amount.to(self.unit, round="up"), ) diff --git a/cashu/lightning/fake.py b/cashu/lightning/fake.py index 111eae1b..97834ee6 100644 --- a/cashu/lightning/fake.py +++ b/cashu/lightning/fake.py @@ -169,5 +169,7 @@ async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: raise NotImplementedError() return PaymentQuoteResponse( - checking_id=invoice_obj.payment_hash, fee=fees, amount=amount + checking_id=invoice_obj.payment_hash, + fee=fees.to(self.unit, round="up"), + amount=amount.to(self.unit, round="up"), ) diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py index 916cb710..96dff6bb 100644 --- a/cashu/lightning/lnbits.py +++ b/cashu/lightning/lnbits.py @@ -165,5 +165,7 @@ async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: fees = Amount(unit=Unit.msat, amount=fees_msat) amount = Amount(unit=Unit.msat, amount=amount_msat) return PaymentQuoteResponse( - checking_id=invoice_obj.payment_hash, fee=fees, amount=amount + checking_id=invoice_obj.payment_hash, + fee=fees.to(self.unit, round="up"), + amount=amount.to(self.unit, round="up"), ) diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index dd47ea0e..29197325 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -272,5 +272,7 @@ async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: fees = Amount(unit=Unit.msat, amount=fees_msat) amount = Amount(unit=Unit.msat, amount=amount_msat) return PaymentQuoteResponse( - checking_id=invoice_obj.payment_hash, fee=fees, amount=amount + checking_id=invoice_obj.payment_hash, + fee=fees.to(self.unit, round="up"), + amount=amount.to(self.unit, round="up"), ) diff --git a/cashu/lightning/strike.py b/cashu/lightning/strike.py index 5abd5fb6..1824c790 100644 --- a/cashu/lightning/strike.py +++ b/cashu/lightning/strike.py @@ -195,27 +195,3 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: fee_msat=data["details"]["fee"], preimage=data["preimage"], ) - - # async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: - # url = f"{self.endpoint}/api/v1/payments/sse" - - # while True: - # try: - # async with requests.stream("GET", url) as r: - # async for line in r.aiter_lines(): - # if line.startswith("data:"): - # try: - # data = json.loads(line[5:]) - # except json.decoder.JSONDecodeError: - # continue - - # if type(data) is not dict: - # continue - - # yield data["payment_hash"] # payment_hash - - # except: - # pass - - # print("lost connection to lnbits /payments/sse, retrying in 5 seconds") - # await asyncio.sleep(5) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index d994448a..255a61bc 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -480,22 +480,14 @@ async def melt_quote( # NOTE: we normalize the request to lowercase to avoid case sensitivity # This works with Lightning but might not work with other methods request = melt_quote.request.lower() - invoice_obj = bolt11.decode(melt_quote.request) - assert invoice_obj.amount_msat, "invoice has no amount." # check if there is a mint quote with the same payment request - # so that we can handle the transaction internally without lightning - # and respond with zero fees + # so that we would be able to handle the transaction internally + # and therefore respond with internal transaction fees (0 for now) mint_quote = await self.crud.get_mint_quote_by_request( request=request, db=self.db ) if mint_quote: - # internal transaction, validate and return amount from - # associated mint quote and demand zero fees - # assert ( - # Amount(unit, mint_quote.amount).to(Unit.msat).amount - # == invoice_obj.amount_msat - # ), "amounts do not match" assert request == mint_quote.request, "bolt11 requests do not match" assert mint_quote.unit == melt_quote.unit, "units do not match" assert mint_quote.method == method.name, "methods do not match" @@ -512,10 +504,23 @@ async def melt_quote( f" {mint_quote.quote} ({mint_quote.amount} {mint_quote.unit})" ) else: - # not internal, get quote by backend + # not internal, get payment quote by backend payment_quote = await self.backends[method][unit].get_payment_quote(request) assert payment_quote.checking_id, "quote has no checking id" + # make sure the backend returned the amount with a correct unit + assert ( + payment_quote.amount.unit == unit + ), "payment quote amount units do not match" + # fee from the backend must be in the same unit as the amount + assert ( + payment_quote.fee.unit == unit + ), "payment quote fee units do not match" + # We assume that the request is a bolt11 invoice, this works since we + # support only the bol11 method for now. + invoice_obj = bolt11.decode(melt_quote.request) + assert invoice_obj.amount_msat, "invoice has no amount." + # we set the expiry of this quote to the expiry of the bolt11 invoice expiry = None if invoice_obj.expiry is not None: expiry = invoice_obj.date + invoice_obj.expiry diff --git a/tests/conftest.py b/tests/conftest.py index f6887cb2..af682cc1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,7 +31,7 @@ settings.mint_url = SERVER_ENDPOINT settings.tor = False settings.wallet_unit = "sat" -settings.mint_lightning_backend = settings.mint_lightning_backend or "FakeWallet" +settings.mint_backend_bolt11_sat = settings.mint_backend_bolt11_sat or "FakeWallet" settings.fakewallet_brr = True settings.fakewallet_delay_payment = False settings.fakewallet_stochastic_invoice = False @@ -111,7 +111,7 @@ async def start_mint_init(ledger: Ledger): await conn.execute("CREATE SCHEMA public;") wallets_module = importlib.import_module("cashu.lightning") - lightning_backend = getattr(wallets_module, settings.mint_lightning_backend)() + lightning_backend = getattr(wallets_module, settings.mint_backend_bolt11_sat)() backends = { Method.bolt11: {Unit.sat: lightning_backend}, } diff --git a/tests/test_mint_lightning_blink.py b/tests/test_mint_lightning_blink.py index 224a6358..85312e15 100644 --- a/tests/test_mint_lightning_blink.py +++ b/tests/test_mint_lightning_blink.py @@ -7,7 +7,7 @@ from cashu.lightning.blink import MINIMUM_FEE_MSAT, BlinkWallet settings.mint_blink_key = "123" -blink = BlinkWallet() +blink = BlinkWallet(unit=Unit.sat) payment_request = ( "lnbc10u1pjap7phpp50s9lzr3477j0tvacpfy2ucrs4q0q6cvn232ex7nt2zqxxxj8gxrsdpv2phhwetjv4jzqcneypqyc6t8dp6xu6twva2xjuzzda6qcqzzsxqrrsss" "p575z0n39w2j7zgnpqtdlrgz9rycner4eptjm3lz363dzylnrm3h4s9qyyssqfz8jglcshnlcf0zkw4qu8fyr564lg59x5al724kms3h6gpuhx9xrfv27tgx3l3u3cyf6" @@ -194,32 +194,32 @@ async def test_blink_get_payment_quote(): respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) quote = await blink.get_payment_quote(payment_request) assert quote.checking_id == payment_request - assert quote.amount == Amount(Unit.msat, 1000000) # msat - assert quote.fee == Amount(Unit.msat, 5000) # msat + assert quote.amount == Amount(Unit.sat, 1000) # sat + assert quote.fee == Amount(Unit.sat, 5) # sat # response says 10 sat fees but invoice (1000 sat) * 0.5% is 5 sat so we expect 10 sat mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 10}}} respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) quote = await blink.get_payment_quote(payment_request) assert quote.checking_id == payment_request - assert quote.amount == Amount(Unit.msat, 1000000) # msat - assert quote.fee == Amount(Unit.msat, 10000) # msat + assert quote.amount == Amount(Unit.sat, 1000) # sat + assert quote.fee == Amount(Unit.sat, 10) # sat # response says 10 sat fees but invoice (4973 sat) * 0.5% is 24.865 sat so we expect 25 sat mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 10}}} respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) quote = await blink.get_payment_quote(payment_request_4973) assert quote.checking_id == payment_request_4973 - assert quote.amount == Amount(Unit.msat, 4973000) # msat - assert quote.fee == Amount(Unit.msat, 25000) # msat + assert quote.amount == Amount(Unit.sat, 4973) # sat + assert quote.fee == Amount(Unit.sat, 25) # sat # response says 0 sat fees but invoice (1 sat) * 0.5% is 0.005 sat so we expect MINIMUM_FEE_MSAT/1000 sat mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 0}}} respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) quote = await blink.get_payment_quote(payment_request_1) assert quote.checking_id == payment_request_1 - assert quote.amount == Amount(Unit.msat, 1000) # msat - assert quote.fee == Amount(Unit.msat, MINIMUM_FEE_MSAT) # msat + assert quote.amount == Amount(Unit.sat, 1) # sat + assert quote.fee == Amount(Unit.sat, MINIMUM_FEE_MSAT // 1000) # msat @respx.mock @@ -230,5 +230,5 @@ async def test_blink_get_payment_quote_backend_error(): respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) quote = await blink.get_payment_quote(payment_request) assert quote.checking_id == payment_request - assert quote.amount == Amount(Unit.msat, 1000000) # msat - assert quote.fee == Amount(Unit.msat, 5000) # msat + assert quote.amount == Amount(Unit.sat, 1000) # sat + assert quote.fee == Amount(Unit.sat, 5) # sat diff --git a/tests/test_mint_operations.py b/tests/test_mint_operations.py index d5bca559..faa58df5 100644 --- a/tests/test_mint_operations.py +++ b/tests/test_mint_operations.py @@ -118,7 +118,7 @@ async def test_mint_external(wallet1: Wallet, ledger: Ledger): quote = await ledger.mint_quote(PostMintQuoteRequest(amount=128, unit="sat")) mint_quote = await ledger.get_mint_quote(quote.quote) - assert not mint_quote.paid, "mint quote not should be paid" + assert not mint_quote.paid, "mint quote already paid" await assert_err( wallet1.mint(128, id=quote.quote), From adeec000a722e384ffc555923196493e5bb9cef4 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 23 Mar 2024 02:25:19 +0100 Subject: [PATCH 14/16] Mint: Add slowapi (#481) * Add slowapi * fix startup * adjust settings * add rate limits to tx routes * elastic --- .env.example | 17 +++- cashu/core/settings.py | 67 ++++++++----- cashu/mint/app.py | 84 +++++------------ cashu/mint/limit.py | 41 ++++++++ cashu/mint/middleware.py | 55 +++++++++++ cashu/mint/router.py | 26 +++-- cashu/mint/router_deprecated.py | 14 ++- poetry.lock | 162 +++++++++++++++++++++++++++++++- pyproject.toml | 1 + 9 files changed, 370 insertions(+), 97 deletions(-) create mode 100644 cashu/mint/limit.py create mode 100644 cashu/mint/middleware.py diff --git a/.env.example b/.env.example index f0fcb4fd..1112a801 100644 --- a/.env.example +++ b/.env.example @@ -86,10 +86,19 @@ LIGHTNING_FEE_PERCENT=1.0 # minimum fee to reserve LIGHTNING_RESERVE_FEE_MIN=2000 -# Management -# max peg-in amount in satoshis +# Limits + +# Max peg-in amount in satoshis # MINT_MAX_PEG_IN=100000 -# max peg-out amount in satoshis +# Max peg-out amount in satoshis # MINT_MAX_PEG_OUT=100000 -# use to allow only peg-out to LN +# Use to allow only peg-out to LN # MINT_PEG_OUT_ONLY=FALSE + +# Rate limit requests to mint. Make sure that you can see request IPs in the logs. +# You may need to adjust your reverse proxy if you only see requests originating from 127.0.0.1 +# MINT_RATE_LIMIT=TRUE +# Determines the number of all requests allowed per minute per IP +# MINT_GLOBAL_RATE_LIMIT_PER_MINUTE=60 +# Determines the number of transactions (mint, melt, swap) allowed per minute per IP +# MINT_TRANSACTION_RATE_LIMIT_PER_MINUTE=20 diff --git a/cashu/core/settings.py b/cashu/core/settings.py index f1f1946a..1c0ee68b 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -56,12 +56,51 @@ class MintSettings(CashuSettings): mint_listen_host: str = Field(default="127.0.0.1") mint_listen_port: int = Field(default=3338) + mint_database: str = Field(default="data/mint") + mint_test_database: str = Field(default="test_data/test_mint") + mint_duplicate_keysets: bool = Field( + default=True, + title="Duplicate keysets", + description=( + "Whether to duplicate keysets for backwards compatibility before v1 API" + " (Nutshell 0.15.0)." + ), + ) + + +class MintBackends(MintSettings): mint_lightning_backend: str = Field(default="") # deprecated mint_backend_bolt11_sat: str = Field(default="") mint_backend_bolt11_usd: str = Field(default="") - mint_database: str = Field(default="data/mint") - mint_test_database: str = Field(default="test_data/test_mint") + mint_lnbits_endpoint: str = Field(default=None) + mint_lnbits_key: str = Field(default=None) + mint_strike_key: str = Field(default=None) + mint_blink_key: str = Field(default=None) + + +class MintLimits(MintSettings): + mint_rate_limit: bool = Field( + default=False, title="Rate limit", description="IP-based rate limiter." + ) + mint_global_rate_limit_per_minute: int = Field( + default=60, + gt=0, + title="Global rate limit per minute", + description="Number of requests an IP can make per minute to all endpoints.", + ) + mint_transaction_rate_limit_per_minute: int = Field( + default=20, + gt=0, + title="Transaction rate limit per minute", + description="Number of requests an IP can make per minute to transaction endpoints.", + ) + mint_max_request_length: int = Field( + default=1000, + title="Maximum request length", + description="Maximum length of REST API request arrays.", + ) + mint_peg_out_only: bool = Field( default=False, title="Peg-out only", @@ -77,27 +116,9 @@ class MintSettings(CashuSettings): title="Maximum peg-out", description="Maximum amount for a melt operation.", ) - mint_max_request_length: int = Field( - default=1000, - title="Maximum request length", - description="Maximum length of REST API request arrays.", - ) mint_max_balance: int = Field( default=None, title="Maximum mint balance", description="Maximum mint balance." ) - mint_duplicate_keysets: bool = Field( - default=True, - title="Duplicate keysets", - description=( - "Whether to duplicate keysets for backwards compatibility before v1 API" - " (Nutshell 0.15.0)." - ), - ) - - mint_lnbits_endpoint: str = Field(default=None) - mint_lnbits_key: str = Field(default=None) - mint_strike_key: str = Field(default=None) - mint_blink_key: str = Field(default=None) class FakeWalletSettings(MintSettings): @@ -138,13 +159,13 @@ class WalletSettings(CashuSettings): "wss://relay.damus.io", "wss://nostr.mom", "wss://relay.snort.social", - "wss://nostr.fmt.wiz.biz", + "wss://nostr.mutinywallet.com", "wss://relay.minibits.cash", "wss://nos.lol", "wss://relay.nostr.band", "wss://relay.bitcoiner.social", "wss://140.f7z.io", - "wss://relayable.org", + "wss://relay.primal.net", ] ) @@ -171,6 +192,8 @@ class Settings( LndRestFundingSource, CoreLightningRestFundingSource, FakeWalletSettings, + MintLimits, + MintBackends, MintSettings, MintInformation, WalletSettings, diff --git a/cashu/mint/app.py b/cashu/mint/app.py index fe7737bb..619481a4 100644 --- a/cashu/mint/app.py +++ b/cashu/mint/app.py @@ -2,14 +2,9 @@ from traceback import print_exception from fastapi import FastAPI, status -from fastapi.exception_handlers import ( - request_validation_exception_handler as _request_validation_exception_handler, -) from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from loguru import logger -from starlette.middleware import Middleware -from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request from ..core.errors import CashuError @@ -20,43 +15,26 @@ from .startup import start_mint_init if settings.debug_profiling: - from fastapi_profiler import PyInstrumentProfilerMiddleware + pass -# from starlette_context import context -# from starlette_context.middleware import RawContextMiddleware +if settings.mint_rate_limit: + pass +from .middleware import add_middlewares, request_validation_exception_handler -# class CustomHeaderMiddleware(BaseHTTPMiddleware): -# """ -# Middleware for starlette that can set the context from request headers -# """ - -# async def dispatch(self, request, call_next): -# context["client-version"] = request.headers.get("Client-version") -# response = await call_next(request) -# return response +# this errors with the tests but is the appropriate way to handle startup and shutdown +# until then, we use @app.on_event("startup") +# @asynccontextmanager +# async def lifespan(app: FastAPI): +# # startup routines here +# await start_mint_init() +# yield +# # shutdown routines here def create_app(config_object="core.settings") -> FastAPI: configure_logger() - # middleware = [ - # Middleware( - # RawContextMiddleware, - # ), - # Middleware(CustomHeaderMiddleware), - # ] - - middleware = [ - Middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], - expose_headers=["*"], - ) - ] - app = FastAPI( title="Nutshell Cashu Mint", description="Ecash wallet and mint based on the Cashu protocol.", @@ -65,18 +43,16 @@ def create_app(config_object="core.settings") -> FastAPI: "name": "MIT License", "url": "https://raw.githubusercontent.com/cashubtc/cashu/main/LICENSE", }, - middleware=middleware, ) - if settings.debug_profiling: - assert PyInstrumentProfilerMiddleware is not None - app.add_middleware(PyInstrumentProfilerMiddleware) - return app app = create_app() +# Add middlewares +add_middlewares(app) + @app.middleware("http") async def catch_exceptions(request: Request, call_next): @@ -113,33 +89,17 @@ async def catch_exceptions(request: Request, call_next): ) -async def request_validation_exception_handler( - request: Request, exc: RequestValidationError -) -> JSONResponse: - """ - This is a wrapper to the default RequestValidationException handler of FastAPI. - This function will be called when client input is not valid. - """ - query_params = request.query_params._dict - detail = { - "errors": exc.errors(), - "query_params": query_params, - } - # log the error - logger.error(detail) - # pass on - return await _request_validation_exception_handler(request, exc) - - -@app.on_event("startup") -async def startup_mint(): - await start_mint_init() - +# Add exception handlers +app.add_exception_handler(RequestValidationError, request_validation_exception_handler) +# Add routers if settings.debug_mint_only_deprecated: app.include_router(router=router_deprecated, tags=["Deprecated"], deprecated=True) else: app.include_router(router=router, tags=["Mint"]) app.include_router(router=router_deprecated, tags=["Deprecated"], deprecated=True) -app.add_exception_handler(RequestValidationError, request_validation_exception_handler) + +@app.on_event("startup") +async def startup_mint(): + await start_mint_init() diff --git a/cashu/mint/limit.py b/cashu/mint/limit.py new file mode 100644 index 00000000..1a8a4c28 --- /dev/null +++ b/cashu/mint/limit.py @@ -0,0 +1,41 @@ +from fastapi import status +from fastapi.responses import JSONResponse +from loguru import logger +from slowapi import Limiter +from slowapi.util import get_remote_address +from starlette.requests import Request + +from ..core.settings import settings + + +def _rate_limit_exceeded_handler(request: Request, exc: Exception) -> JSONResponse: + remote_address = get_remote_address(request) + logger.warning( + f"Rate limit {settings.mint_global_rate_limit_per_minute}/minute exceeded: {remote_address}" + ) + return JSONResponse( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + content={"detail": "Rate limit exceeded."}, + ) + + +def get_remote_address_excluding_local(request: Request) -> str: + remote_address = get_remote_address(request) + if remote_address == "127.0.0.1": + return "" + return remote_address + + +limiter_global = Limiter( + key_func=get_remote_address_excluding_local, + strategy="fixed-window-elastic-expiry", + default_limits=[f"{settings.mint_global_rate_limit_per_minute}/minute"], + enabled=settings.mint_rate_limit, +) + +limiter = Limiter( + key_func=get_remote_address_excluding_local, + strategy="fixed-window-elastic-expiry", + default_limits=[f"{settings.mint_transaction_rate_limit_per_minute}/minute"], + enabled=settings.mint_rate_limit, +) diff --git a/cashu/mint/middleware.py b/cashu/mint/middleware.py new file mode 100644 index 00000000..f70dbc9c --- /dev/null +++ b/cashu/mint/middleware.py @@ -0,0 +1,55 @@ +from fastapi import FastAPI +from fastapi.exception_handlers import ( + request_validation_exception_handler as _request_validation_exception_handler, +) +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from loguru import logger +from starlette.middleware.cors import CORSMiddleware +from starlette.requests import Request + +from ..core.settings import settings +from .limit import _rate_limit_exceeded_handler, limiter_global + +if settings.debug_profiling: + from fastapi_profiler import PyInstrumentProfilerMiddleware + +from slowapi.errors import RateLimitExceeded +from slowapi.middleware import SlowAPIMiddleware + + +def add_middlewares(app: FastAPI): + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["*"], + ) + + if settings.debug_profiling: + assert PyInstrumentProfilerMiddleware is not None + app.add_middleware(PyInstrumentProfilerMiddleware) + + if settings.mint_rate_limit: + app.state.limiter = limiter_global + app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + app.add_middleware(SlowAPIMiddleware) + + +async def request_validation_exception_handler( + request: Request, exc: RequestValidationError +) -> JSONResponse: + """ + This is a wrapper to the default RequestValidationException handler of FastAPI. + This function will be called when client input is not valid. + """ + query_params = request.query_params._dict + detail = { + "errors": exc.errors(), + "query_params": query_params, + } + # log the error + logger.error(detail) + # pass on + return await _request_validation_exception_handler(request, exc) diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 19e6f46b..cd7b6cf8 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -1,6 +1,6 @@ from typing import Any, Dict, List -from fastapi import APIRouter +from fastapi import APIRouter, Request from loguru import logger from ..core.base import ( @@ -28,6 +28,7 @@ from ..core.errors import CashuError from ..core.settings import settings from ..mint.startup import ledger +from .limit import limiter router: APIRouter = APIRouter() @@ -178,7 +179,10 @@ async def keysets() -> KeysetsResponse: response_model=PostMintQuoteResponse, response_description="A payment request to mint tokens of a denomination", ) -async def mint_quote(payload: PostMintQuoteRequest) -> PostMintQuoteResponse: +@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") +async def mint_quote( + request: Request, payload: PostMintQuoteRequest +) -> PostMintQuoteResponse: """ Request minting of new tokens. The mint responds with a Lightning invoice. This endpoint can be used for a Lightning invoice UX flow. @@ -203,7 +207,8 @@ async def mint_quote(payload: PostMintQuoteRequest) -> PostMintQuoteResponse: response_model=PostMintQuoteResponse, response_description="Get an existing mint quote to check its status.", ) -async def get_mint_quote(quote: str) -> PostMintQuoteResponse: +@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") +async def get_mint_quote(request: Request, quote: str) -> PostMintQuoteResponse: """ Get mint quote state. """ @@ -228,7 +233,9 @@ async def get_mint_quote(quote: str) -> PostMintQuoteResponse: "A list of blinded signatures that can be used to create proofs." ), ) +@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") async def mint( + request: Request, payload: PostMintRequest, ) -> PostMintResponse: """ @@ -250,7 +257,10 @@ async def mint( response_model=PostMeltQuoteResponse, response_description="Melt tokens for a payment on a supported payment method.", ) -async def get_melt_quote(payload: PostMeltQuoteRequest) -> PostMeltQuoteResponse: +@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") +async def get_melt_quote( + request: Request, payload: PostMeltQuoteRequest +) -> PostMeltQuoteResponse: """ Request a quote for melting tokens. """ @@ -266,7 +276,8 @@ async def get_melt_quote(payload: PostMeltQuoteRequest) -> PostMeltQuoteResponse response_model=PostMeltQuoteResponse, response_description="Get an existing melt quote to check its status.", ) -async def melt_quote(quote: str) -> PostMeltQuoteResponse: +@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") +async def melt_quote(request: Request, quote: str) -> PostMeltQuoteResponse: """ Get melt quote state. """ @@ -296,7 +307,8 @@ async def melt_quote(quote: str) -> PostMeltQuoteResponse: " promises for change." ), ) -async def melt(payload: PostMeltRequest) -> PostMeltResponse: +@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") +async def melt(request: Request, payload: PostMeltRequest) -> PostMeltResponse: """ Requests tokens to be destroyed and sent out via Lightning. """ @@ -320,7 +332,9 @@ async def melt(payload: PostMeltRequest) -> PostMeltResponse: "An array of blinded signatures that can be used to create proofs." ), ) +@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") async def swap( + request: Request, payload: PostSplitRequest, ) -> PostSplitResponse: """ diff --git a/cashu/mint/router_deprecated.py b/cashu/mint/router_deprecated.py index e1901d7b..4a970c4f 100644 --- a/cashu/mint/router_deprecated.py +++ b/cashu/mint/router_deprecated.py @@ -1,6 +1,6 @@ from typing import Dict, List, Optional -from fastapi import APIRouter +from fastapi import APIRouter, Request from loguru import logger from ..core.base import ( @@ -28,6 +28,7 @@ ) from ..core.errors import CashuError from ..core.settings import settings +from .limit import limiter from .startup import ledger router_deprecated: APIRouter = APIRouter() @@ -129,7 +130,10 @@ async def keysets_deprecated() -> KeysetsResponse_deprecated: ), deprecated=True, ) -async def request_mint_deprecated(amount: int = 0) -> GetMintResponse_deprecated: +@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") +async def request_mint_deprecated( + request: Request, amount: int = 0 +) -> GetMintResponse_deprecated: """ Request minting of new tokens. The mint responds with a Lightning invoice. This endpoint can be used for a Lightning invoice UX flow. @@ -157,7 +161,9 @@ async def request_mint_deprecated(amount: int = 0) -> GetMintResponse_deprecated ), deprecated=True, ) +@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") async def mint_deprecated( + request: Request, payload: PostMintRequest_deprecated, hash: Optional[str] = None, payment_hash: Optional[str] = None, @@ -204,7 +210,9 @@ async def mint_deprecated( ), deprecated=True, ) +@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") async def melt_deprecated( + request: Request, payload: PostMeltRequest_deprecated, ) -> PostMeltResponse_deprecated: """ @@ -267,7 +275,9 @@ async def check_fees( ), deprecated=True, ) +@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") async def split_deprecated( + request: Request, payload: PostSplitRequest_Deprecated, # ) -> Union[PostSplitResponse_Very_Deprecated, PostSplitResponse_Deprecated]: ): diff --git a/poetry.lock b/poetry.lock index ee07069b..d34e7582 100644 --- a/poetry.lock +++ b/poetry.lock @@ -400,6 +400,23 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "deprecated" +version = "1.2.14" +description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"}, + {file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"}, +] + +[package.dependencies] +wrapt = ">=1.10,<2" + +[package.extras] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"] + [[package]] name = "distlib" version = "0.3.8" @@ -615,6 +632,24 @@ docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.link perf = ["ipython"] testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] +[[package]] +name = "importlib-resources" +version = "6.3.1" +description = "Read resources from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_resources-6.3.1-py3-none-any.whl", hash = "sha256:4811639ca7fa830abdb8e9ca0a104dc6ad13de691d9fe0d3173a71304f068159"}, + {file = "importlib_resources-6.3.1.tar.gz", hash = "sha256:29a3d16556e330c3c8fb8202118c5ff41241cc34cbfb25989bbad226d99b7995"}, +] + +[package.dependencies] +zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["jaraco.collections", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)", "zipp (>=3.17)"] + [[package]] name = "iniconfig" version = "2.0.0" @@ -626,6 +661,35 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "limits" +version = "3.10.0" +description = "Rate limiting utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "limits-3.10.0-py3-none-any.whl", hash = "sha256:3e617a580f57a21b39393f833c27ad0378c87b309e908c154ee69e6740041959"}, + {file = "limits-3.10.0.tar.gz", hash = "sha256:6e657dccafce64fd8ee023ebf4593cd47e9eac841fd1dec3448f48673ba10b7c"}, +] + +[package.dependencies] +deprecated = ">=1.2" +importlib-resources = ">=1.3" +packaging = ">=21,<24" +typing-extensions = "*" + +[package.extras] +all = ["aetcd", "coredis (>=3.4.0,<5)", "emcache (>=0.6.1)", "emcache (>=1)", "etcd3", "motor (>=3,<4)", "pymemcache (>3,<5.0.0)", "pymongo (>4.1,<5)", "redis (>3,!=4.5.2,!=4.5.3,<6.0.0)", "redis (>=4.2.0,!=4.5.2,!=4.5.3)"] +async-etcd = ["aetcd"] +async-memcached = ["emcache (>=0.6.1)", "emcache (>=1)"] +async-mongodb = ["motor (>=3,<4)"] +async-redis = ["coredis (>=3.4.0,<5)"] +etcd = ["etcd3"] +memcached = ["pymemcache (>3,<5.0.0)"] +mongodb = ["pymongo (>4.1,<5)"] +redis = ["redis (>3,!=4.5.2,!=4.5.3,<6.0.0)"] +rediscluster = ["redis (>=4.2.0,!=4.5.2,!=4.5.3)"] + [[package]] name = "loguru" version = "0.7.2" @@ -1316,6 +1380,23 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "slowapi" +version = "0.1.9" +description = "A rate limiting extension for Starlette and Fastapi" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36"}, + {file = "slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77"}, +] + +[package.dependencies] +limits = ">=2.3" + +[package.extras] +redis = ["redis (>=3.4.1,<4.0.0)"] + [[package]] name = "sniffio" version = "1.3.0" @@ -1537,6 +1618,85 @@ files = [ [package.extras] dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] +[[package]] +name = "wrapt" +version = "1.16.0" +description = "Module for decorators, wrappers and monkey patching." +optional = false +python-versions = ">=3.6" +files = [ + {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, + {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, + {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, + {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, + {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, + {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, + {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, + {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, + {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"}, + {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"}, + {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"}, + {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, + {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, + {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, + {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, + {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, + {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, + {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, +] + [[package]] name = "zipp" version = "3.17.0" @@ -1558,4 +1718,4 @@ pgsql = ["psycopg2-binary"] [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "94a66019b5c9fd191e33aa9c9a2a6a22a2a0db1d60110e858673738738ece902" +content-hash = "d941bf9a1f3f01b6d9e9e16118b1ae6dfa2244b80a6433728a4e67a77420a527" diff --git a/pyproject.toml b/pyproject.toml index 917a8dba..0448d665 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ bip32 = "^3.4" mnemonic = "^0.20" bolt11 = "^2.0.5" pre-commit = "^3.5.0" +slowapi = "^0.1.9" [tool.poetry.extras] pgsql = ["psycopg2-binary"] From 3feb02312e021c1f186fe13383510964a6744e67 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 23 Mar 2024 03:08:13 +0100 Subject: [PATCH 15/16] fix: fakewallet payment fee unit (#490) --- cashu/lightning/fake.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cashu/lightning/fake.py b/cashu/lightning/fake.py index 97834ee6..f4c0f018 100644 --- a/cashu/lightning/fake.py +++ b/cashu/lightning/fake.py @@ -123,7 +123,7 @@ async def pay_invoice(self, quote: MeltQuote, fee_limit: int) -> PaymentResponse return PaymentResponse( ok=True, checking_id=invoice.payment_hash, - fee=Amount(unit=Unit.msat, amount=0), + fee=Amount(unit=self.unit, amount=1), preimage=self.payment_secrets.get(invoice.payment_hash) or "0" * 64, ) else: @@ -164,7 +164,7 @@ async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: elif self.unit == Unit.usd: amount_usd = math.ceil(invoice_obj.amount_msat / 1e9 * self.fake_btc_price) amount = Amount(unit=Unit.usd, amount=amount_usd) - fees = Amount(unit=Unit.usd, amount=1) + fees = Amount(unit=Unit.usd, amount=2) else: raise NotImplementedError() From e04047979515c386af11b9d5f2010f7a8d3f0b30 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 26 Mar 2024 12:00:13 +0100 Subject: [PATCH 16/16] Fix CLNRest return model (#494) * returs different model * new way to get local balance --- cashu/lightning/corelightningrest.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cashu/lightning/corelightningrest.py b/cashu/lightning/corelightningrest.py index da36cdc1..4503f6a0 100644 --- a/cashu/lightning/corelightningrest.py +++ b/cashu/lightning/corelightningrest.py @@ -70,7 +70,7 @@ async def cleanup(self): logger.warning(f"Error closing wallet connection: {e}") async def status(self) -> StatusResponse: - r = await self.client.get(f"{self.url}/v1/channel/localremotebal", timeout=5) + r = await self.client.get(f"{self.url}/v1/listFunds", timeout=5) r.raise_for_status() if r.is_error or "error" in r.json(): try: @@ -88,7 +88,7 @@ async def status(self) -> StatusResponse: data = r.json() if len(data) == 0: return StatusResponse(error_message="no data", balance=0) - balance_msat = int(data.get("localBalance") * 1000) + balance_msat = int(sum([c["our_amount_msat"] for c in data["channels"]])) return StatusResponse(error_message=None, balance=balance_msat) async def create_invoice( @@ -212,7 +212,7 @@ async def pay_invoice( checking_id = data["payment_hash"] preimage = data["payment_preimage"] - fee_msat = data["msatoshi_sent"] - data["msatoshi"] + fee_msat = data["amount_sent_msat"] - data["amount_msat"] return PaymentResponse( ok=self.statuses.get(data["status"]),