diff --git a/README.md b/README.md index 4576ded..9f78ac4 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,305 @@ -# Example Extension - [LNbits](https://github.com/lnbits/lnbits) extension +# SiLNt — Silent Payments Wallet Extension for LNbits -For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions) +A [LNbits](https://lnbits.com) extension for managing [Silent Payment](https://silentpayments.xyz) Bitcoin wallets, with blockchain scanning powered by a self-hosted [BlindBit Oracle](https://github.com/ponthief/blindbit-oracle). -

*tagline*

-This is an example extension to help you organise and build you own. +--- -Try to include an image - +## Features -

If your extension has API endpoints, include useful ones here

+- Generate Silent Payment addresses from a BIP39 mnemonic +- Store and manage multiple Silent Payment wallet accounts per user +- Generate up to 10 BIP352 labeled SP subaccount addresses per wallet +- Human Readable Address support ([BIP353](https://github.com/bitcoin/bips/blob/master/bip-0353.mediawiki) email format) — validated against SP address on create/update +- Blockchain scanning via a self-hosted BlindBit Oracle with real-time progress tracking and stop/resume +- UTXO tracking with automatic balance updates (unspent only) +- Send to Silent Payment, on-chain, or BIP353 email addresses +- Configurable Mempool URL (supports local instances via http or https) +- Admin-controlled BlindBit Oracle connection settings +- QR code display for SP addresses and subaccount addresses -curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY" +--- + +## Requirements + +- LNbits instance (self-hosted) +- Python dependencies: `embit`, `httpx`, `coincurve`, `cryptography`, `dnspython`, `ecdsa` +- A running [blindbit-oracle](https://github.com/ponthief/blindbit-oracle) instance for blockchain scanning + +--- + +## Installation + +1. As Admin user, navigate to **Settings → Extensions** and add Source: + [Ponthief-Extensions](https://raw.githubusercontent.com/ponthief/lnbits-extensions/extensions/extensions.json) +2. Install/Enable the extension from the LNbits admin panel under **Extensions**. +3. Database migrations run automatically on first load. + +--- + +## Configuration + +### BlindBit Oracle Connection + +Before scanning, an admin must configure the BlindBit Oracle connection via the **Settings** button (⚙️) in the extension UI, or via the API: + +```bash +curl -X PUT https:///siLNt/api/v1/backend/config \ + -H "X-Api-Key: " \ + -H "Content-Type: application/json" \ + -d '{ + "blindbit_url": "http://localhost:8001", + "blindbit_user": "", + "blindbit_pass": "", + "mempool_url": "https://mempool.space" + }' +``` + +### Mempool URL + +The Mempool URL is configured alongside the BlindBit Oracle settings. It defaults to `https://mempool.space` but can be pointed to a local Mempool instance for added privacy. Both `http` and `https` are supported. + +--- + +## Usage + +### 1. Add a Wallet Account + +Click **Silent Payments Wallet Account → New Wallet Account** and fill in: + +| Field | Description | +|---|---| +| Mnemonic | 12-word BIP39 seed phrase (AES-encrypted client-side, never stored) | +| Born at Height | Block height of the wallet's first transaction — reduces scan time | +| Human Readable Address | Optional BIP353 email-format address (e.g. `alice@domain.com`) — must resolve to this wallet's SP address | + +> The mnemonic is AES-encrypted using the born-at height as the key before transmission. It is never stored in the database. + +### 2. Generate Labeled SP Addresses (Subaccounts) + +Click **+** on a wallet row to generate a new BIP352 labeled SP address (up to 10 per wallet). Labeled addresses appear inline below the main SP address with an amber border. Click **Save** to persist to the database — unsaved addresses are marked with an `unsaved` badge. + +### 3. Scan the Blockchain + +Click the **Bitcoin** icon button on a wallet row to open the scan dialog. The dialog shows: +- **Scan From** — last scanned height (editable) +- **Chain Tip** — fetched live from the Oracle (editable) +- **Blocks to Scan** — calculated automatically + +Click **Sync to Tip** to start scanning. A progress bar shows real-time progress. Click **Stop** to pause — progress is saved and the next scan resumes from where it left off. + +### 4. Load UTXOs from DB + +Click the **database** icon button on a wallet row to load previously scanned UTXOs from the local database. + +### 5. Make a Payment + +Click **Send** to open the Send Payment flow: +1. Select UTXOs to spend (checkbox + amount shown) +2. Enter recipient (SP address, on-chain address, or BIP353 email) +3. Set amount and fee rate +4. Click **Build Transaction** — reviews fee before broadcasting +5. Click **Broadcast** → confirm in the confirmation dialog + +After broadcast, selected UTXOs are marked as spent and a Mempool link is shown in the notification. + +### 6. Resolve BIP353 + +Click **Resolve BIP353** to look up a BIP353 email-format address and display the resolved SP address. + +--- + +## API Reference + +All endpoints are prefixed with `/siLNt/api/v1`. Authentication uses the `X-Api-Key` header. + +### Wallets + +| Method | Endpoint | Auth | Description | +|---|---|---|---| +| `GET` | `/wallet` | Invoice Key | List all wallet accounts | +| `GET` | `/wallet/{wallet_id}` | Invoice Key | Get a wallet account | +| `POST` | `/wallet` | Invoice Key | Create a wallet account | +| `PUT` | `/wallet/{wallet_id}` | Invoice Key | Update hr_address, last_height, title, balance | +| `DELETE` | `/wallet/{wallet_id}` | Invoice Key | Delete wallet, UTXOs and labeled addresses | + +### Labeled SP Addresses + +| Method | Endpoint | Auth | Description | +|---|---|---|---| +| `GET` | `/wallet/{wallet_id}/addresses` | Invoice Key | List saved labeled SP addresses | +| `POST` | `/wallet/{wallet_id}/addresses/preview` | Invoice Key | Preview a labeled SP address (not saved) | +| `POST` | `/wallet/{wallet_id}/addresses` | Invoice Key | Save a labeled SP address to DB | +| `DELETE` | `/wallet/{wallet_id}/addresses/{address_id}` | Invoice Key | Delete a labeled SP address | + +### Scanning + +| Method | Endpoint | Auth | Description | +|---|---|---|---| +| `POST` | `/wallet/{wallet_id}/scan` | Invoice Key | Scan blockchain for UTXOs | +| `POST` | `/wallet/{wallet_id}/scan/stop` | Invoice Key | Stop an in-progress scan | +| `GET` | `/wallet/{wallet_id}/scan/progress` | Invoice Key | Get real-time scan progress | + +### UTXOs + +| Method | Endpoint | Auth | Description | +|---|---|---|---| +| `GET` | `/utxos?wallet_id=` | Invoice Key | Load UTXOs from DB for a wallet | + +### BlindBit Oracle + +| Method | Endpoint | Auth | Description | +|---|---|---|---| +| `GET` | `/blindbit/config` | Invoice Key | Get Oracle connection settings | +| `PUT` | `/blindbit/config` | Admin Key | Update Oracle connection settings incl. Mempool URL | +| `GET` | `/oracle/tip` | Invoice Key | Get current chain tip from Oracle | + +### BIP353 + +| Method | Endpoint | Auth | Description | +|---|---|---|---| +| `GET` | `/bip353/resolve?address=` | Invoice Key | Resolve a BIP353 email-format address | + +### Transactions + +| Method | Endpoint | Auth | Description | +|---|---|---|---| +| `POST` | `/tx/build` | Admin Key | Build and sign a transaction | +| `POST` | `/tx/broadcast` | Admin Key | Broadcast a signed transaction | + +### Config + +| Method | Endpoint | Auth | Description | +|---|---|---|---| +| `GET` | `/config` | Invoice Key | Get app config including mempool endpoint | + +Full interactive docs at `/docs#/siLNt` on your LNbits instance. + +--- + +## Data Models + +### WalletAccount + +```json +{ + "id": "abc123xyz", + "user": "usr_abc123", + "title": "sp1qqw...", + "balance": 100000, + "hr_address": "alice@domain.com", + "network": "mainnet", + "last_height": 840000, + "last_scan_height": 842000, + "sp_address": "sp1qqw..." +} +``` + +### WalletAddress (Labeled SP) + +```json +{ + "id": "xyz789", + "wallet_id": "abc123xyz", + "sp_address": "sp1qq...", + "label_index": 1, + "created_at": 1710000000 +} +``` + +### BackendConfig + +```json +{ + "blindbit_url": "http://localhost:8001", + "blindbit_user": "", + "blindbit_pass": "", + "mempool_url": "https://mempool.space" +} +``` + +### UTXORecord + +```json +{ + "txid": "a1b2c3...", + "vout": 0, + "amount": 50000, + "priv_key_tweak": "...", + "pub_key": "...", + "timestamp": 1710000000, + "utxo_state": "unspent", + "wallet_id": "abc123xyz" +} +``` + +--- + +## Security Notes + +- Mnemonics are **never stored**. AES-encrypted client-side before transmission, used only to derive keys at creation time. +- The `scan_secret` (scan private key) is encrypted at rest using a server-side Fernet key. +- The `spend_key` is encrypted at rest using the `scan_secret` as the AES key — double-layered protection. +- BIP353 `hr_address` is validated server-side on create and update — it must resolve to the wallet's SP address. +- Configure `mempool_url` to point to a local Mempool instance for transaction broadcasting privacy. +- Admin Key is required for all write operations that affect funds (tx build, broadcast, BlindBit config). + +--- + +## Project Structure + +``` +siLNt/ +├── __init__.py +├── views.py # Page routes +├── views_api.py # REST API endpoints +├── crud.py # Database operations +├── models.py # Pydantic models +├── migrations.py # DB schema migrations +├── helpers/ +│ ├── wallet.py # SP address derivation, key encryption, tx building +│ ├── scan.py # Blockchain scanner (BlindBit Oracle client) +│ ├── address_resolver.py # BIP353 DNS resolution +│ └── curve.py # secp256k1 EC math helpers +├── static/ +│ ├── js/ +│ │ ├── index.js # Main Vue app +│ │ ├── tables.js # Table column definitions +│ │ ├── map.js # Data mapping functions +│ │ ├── utils.js # Utility functions +│ │ └── bip39-word-list.js # BIP39 word list for mnemonic validation +│ └── components/ +│ ├── wallet-config.js / .html # BlindBit Oracle settings +│ ├── wallet-list.js / .html # Wallet table with labeled addresses +│ └── utxo-list.js / .html # UTXO table +└── templates/ + └── silnt/ + ├── index.html + └── _api_docs.html +``` + +--- + +## References + +- [BIP352 — Silent Payments](https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki) +- [BIP353 — DNS Payment Instructions](https://github.com/bitcoin/bips/blob/master/bip-0353.mediawiki) +- [BIP352 Light Client Specification](https://github.com/setavenger/BIP0352-light-client-specification) +- [BlindBit Oracle](https://github.com/ponthief/blindbit-oracle) + +--- + +## Contributing + +Pull requests welcome. Please open an issue first to discuss significant changes. + +--- + +## Author + +Created by [Ponthief](https://github.com/ponthief) at [Bitaurus](https://bitaurus.net) + +--- + +## License + +MIT \ No newline at end of file diff --git a/__init__.py b/__init__.py index 353d3b4..858894b 100644 --- a/__init__.py +++ b/__init__.py @@ -1,46 +1,80 @@ import asyncio - from fastapi import APIRouter +from fastapi.staticfiles import StaticFiles from loguru import logger +from lnbits.tasks import create_permanent_unique_task from .crud import db -from .tasks import wait_for_paid_invoices -from .views import example_ext_generic -from .views_api import example_ext_api +from .views import silnt_generic_router +from .views_api import silnt_api_router, run_bitmail_tamper_sweep, run_health_probes +from .boltz_swap import silnt_boltz_router +from .boltz_refund_api import silnt_refund_router +from .boltz_refund_api import refund_due_swaps -example_ext: APIRouter = APIRouter(prefix="/example", tags=["example"]) -example_ext.include_router(example_ext_generic) -example_ext.include_router(example_ext_api) -example_static_files = [ +siLNt_static_files = [ { - "path": "/example/static", - "name": "example_static", + "path": "/siLNt/static", + "app": StaticFiles(packages=[("lnbits", "extensions/siLNt/static")]), + "name": "siLNt_static", } ] -scheduled_tasks: list[asyncio.Task] = [] +siLNt_ext: APIRouter = APIRouter(prefix="/siLNt", tags=["siLNt"]) +siLNt_ext.include_router(silnt_generic_router) +siLNt_ext.include_router(silnt_api_router) +siLNt_ext.include_router(silnt_boltz_router) +siLNt_ext.include_router(silnt_refund_router) + +scheduled_tasks: list[asyncio.Task] = [] -def example_stop(): - for task in scheduled_tasks: +async def _tamper_sweep_loop(): + while True: try: - task.cancel() - except Exception as ex: - logger.warning(ex) + res = await run_bitmail_tamper_sweep() + if res and res.get("mismatches"): + logger.warning(f"[silnt] tamper sweep: {res}") + except Exception as exc: + logger.error(f"[silnt] tamper sweep loop error: {exc}") + await asyncio.sleep(300) # every 5 min +async def _refund_loop(): + while True: + try: + results = await refund_due_swaps() + if results: + logger.info(f"[silnt] auto-refund pass: {results}") + except Exception as exc: + logger.error(f"[silnt] auto-refund loop error: {exc}") + await asyncio.sleep(120) # every 2 min; tune as you like -def example_start(): - from lnbits.tasks import create_permanent_unique_task +async def _health_monitor_loop(): + # Probe BlindBit Oracle Fulcrum on a timer so a down (or recovery) fires an + # ntfy even when no admin has the dashboard open. State-change dedup is inside + # notify_service_health_change, so this won't spam while a service stays down. + while True: + try: + await run_health_probes() + except Exception as exc: + logger.error(f"[silnt] health monitor loop error: {exc}") + await asyncio.sleep(60) # every 2 min - task = create_permanent_unique_task("ext_testing", wait_for_paid_invoices) +# in async def silnt_start() / wherever the ext starts its tasks: +def siLNt_start(): + task = create_permanent_unique_task("ext_silnt", _refund_loop) scheduled_tasks.append(task) + tamper_task = create_permanent_unique_task("ext_silnt_tamper", _tamper_sweep_loop) + scheduled_tasks.append(tamper_task) + health_task = create_permanent_unique_task("ext_silnt_health", _health_monitor_loop) + scheduled_tasks.append(health_task) +# in the ext stop hook: +def siLNt_stop(): + for t in scheduled_tasks: + try: + t.cancel() + except Exception as ex: + logger.warning(ex) -__all__ = [ - "db", - "example_ext", - "example_start", - "example_static_files", - "example_stop", -] +__all__ = ["siLNt_ext", "siLNt_static_files", "db", "siLNt_start", "siLNt_stop"] \ No newline at end of file diff --git a/boltz_refund.py b/boltz_refund.py new file mode 100644 index 0000000..c662f71 --- /dev/null +++ b/boltz_refund.py @@ -0,0 +1,391 @@ +""" +boltz_refund.py — script-path refund for a failed Boltz v2 submarine swap-in. + +When a swap-in fails (invoice.failedToPay / transaction.lockupFailed) or simply +isn't claimed, the user's on-chain funds sit in the Boltz lockup Taproot output. +After the swap's timeout block height, they can be refunded via the REFUND LEAF +(script path): single-sig Schnorr by the user's refund key + CLTV. No Musig2 +signing is needed for the script-path refund (only Musig2 KEY AGGREGATION to +rebuild the Taproot internal key for the control block — deterministic, verified +against the BIP-327 test vector). + +SAFETY — this module self-verifies before signing: + It reconstructs the Taproot output address from (claim_pubkey, refund_pubkey, + swap_tree) and asserts it equals the lockup address Boltz actually funded. If + they differ, it RAISES and refuses to sign — so a wrong reconstruction can + never produce a broadcastable (or fund-losing) transaction. This is the same + address-match check that was verified green against real swap data. + +Refund tx shape: + - 1 input: the lockup UTXO (txid:vout, value) + - 1 output: the user's on-chain refund address (a plain address they control) + - nLockTime = timeout_block_height ; input sequence = 0xfffffffe (enables CLTV, + not final) ; must be broadcast at block height >= timeout. + - witness = [schnorr_sig, refund_script, control_block] + +Requires: coincurve (Schnorr). Pure-python secp256k1 is used only for KeyAgg / +point math so the internal-key derivation doesn't depend on coincurve internals. +""" + +import hashlib +from typing import Optional + +from loguru import logger +import coincurve + + +# ── secp256k1 (for KeyAgg / Taproot point math) ─────────────────────────────── +_P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F +_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 +_Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798 +_Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8 +_G = (_Gx, _Gy) + + +def _inv(a, m=_P): + return pow(a, m - 2, m) + + +def _padd(A, B): + if A is None: + return B + if B is None: + return A + x1, y1 = A + x2, y2 = B + if x1 == x2 and (y1 + y2) % _P == 0: + return None + if A == B: + l = (3 * x1 * x1) * _inv(2 * y1) % _P + else: + l = (y2 - y1) * _inv(x2 - x1) % _P + x3 = (l * l - x1 - x2) % _P + return (x3, (l * (x1 - x3) - y1) % _P) + + +def _pneg(Pt): + if Pt is None: + return None + x, y = Pt + return (x, (_P - y) % _P) + + +def _pmul(k, Pt): + R = None + while k: + if k & 1: + R = _padd(R, Pt) + Pt = _padd(Pt, Pt) + k >>= 1 + return R + + +def _lift_x(x): + y2 = (pow(x, 3, _P) + 7) % _P + y = pow(y2, (_P + 1) // 4, _P) + if (y * y) % _P != y2: + raise ValueError("point not on curve") + return (x, _P - y if y & 1 else y) + + +def _ser_x(Pt): + return Pt[0].to_bytes(32, "big") + + +def _even(Pt): + return Pt[1] % 2 == 0 + + +def _tagged(tag, *ms): + t = hashlib.sha256(tag.encode()).digest() + return hashlib.sha256(t + t + b"".join(ms)).digest() + + +def _parse_pub(pk: bytes): + x = int.from_bytes(pk[1:], "big") + Pt = _lift_x(x) + if pk[0] == 3: + Pt = (Pt[0], _P - Pt[1]) + return Pt + + +def _key_agg(pks: list) -> tuple: + """BIP-327 key aggregation (verified against the official test vector).""" + Lc = _tagged("KeyAgg list", b"".join(pks)) + second = next((pk for pk in pks if pk != pks[0]), None) + Q = None + for pk in pks: + a = 1 if pk == second else int.from_bytes(_tagged("KeyAgg coefficient", Lc, pk), "big") % _N + Q = _padd(Q, _pmul(a, _parse_pub(pk))) + return Q + + +# ── bech32m (for the address self-check) ────────────────────────────────────── +_CH = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + +def _poly(v): + g = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3] + c = 1 + for x in v: + b = c >> 25 + c = ((c & 0x1FFFFFF) << 5) ^ x + for i in range(5): + c ^= g[i] if (b >> i) & 1 else 0 + return c + + +def _hrpexp(h): + return [ord(c) >> 5 for c in h] + [0] + [ord(c) & 31 for c in h] + + +def _convertbits(data, frm, to, pad=True): + acc = 0 + bits = 0 + ret = [] + mv = (1 << to) - 1 + for v in data: + acc = (acc << frm) | v + bits += frm + while bits >= to: + bits -= to + ret.append((acc >> bits) & mv) + if pad and bits: + ret.append((acc << (to - bits)) & mv) + return ret + + +def _bech32m_addr(hrp, witver, prog): + data = [witver] + _convertbits(list(prog), 8, 5) + pm = _poly(_hrpexp(hrp) + data + [0, 0, 0, 0, 0, 0]) ^ 0x2BC830A3 + chk = [(pm >> 5 * (5 - i)) & 31 for i in range(6)] + return hrp + "1" + "".join(_CH[d] for d in data + chk) + + +_SEGWIT_HRP = {"regtest": "bcrt", "testnet": "tb", "signet": "tb", "mainnet": "bc"} + + +# ── Taproot reconstruction (control block + output address) ─────────────────── +def _leaf_hash(script: bytes) -> bytes: + return _tagged("TapLeaf", bytes([0xC0, len(script)]) + script) + + +def reconstruct_taproot(claim_pub_hex: str, refund_pub_hex: str, + claim_script_hex: str, refund_script_hex: str, + agg_order: str = "claim_first"): + """ + Returns (internal_key_xonly: bytes, output_key_xonly: bytes, + output_parity: int, control_block: bytes, refund_script: bytes). + The control block is for spending via the REFUND leaf (sibling = claim leaf). + + agg_order controls the BIP-327 KeyAgg input order, which Boltz may set by + sorting the keys lexicographically rather than a fixed claim/refund order. + The exact order changes the aggregate internal key (and thus the address), so + the caller tries both and keeps whichever reconstructs the funded address. + """ + claim_pub = bytes.fromhex(claim_pub_hex) + refund_pub = bytes.fromhex(refund_pub_hex) + claim_script = bytes.fromhex(claim_script_hex) + refund_script = bytes.fromhex(refund_script_hex) + + if agg_order == "refund_first": + agg_keys = [refund_pub, claim_pub] + elif agg_order == "sorted": + agg_keys = sorted([claim_pub, refund_pub]) + else: # "claim_first" (default / original) + agg_keys = [claim_pub, refund_pub] + + internal = _key_agg(agg_keys) + # BIP-341: the taptweak is applied to the internal point represented by its + # x-only key, i.e. the EVEN-Y point. MuSig2 KeyAgg can yield an odd-Y + # aggregate; if so, negate it to the even-Y point before tweaking, else the + # output key (and address) come out wrong. (This is the parity that bit us — + # distinct from key ORDER, which we also try.) + if not _even(internal): + internal = _pneg(internal) + ikey = _ser_x(internal) + + lh_r = _leaf_hash(refund_script) + lh_c = _leaf_hash(claim_script) + root = _tagged("TapBranch", min(lh_r, lh_c) + max(lh_r, lh_c)) + + t = int.from_bytes(_tagged("TapTweak", ikey + root), "big") % _N + Q = _padd(internal, _pmul(t, _G)) + outkey = _ser_x(Q) + parity = 0 if _even(Q) else 1 + + # control block for the refund leaf: [0xc0 | parity] + internal_key + sibling(claim) leaf hash + control_block = bytes([0xC0 | parity]) + ikey + lh_c + return ikey, outkey, parity, control_block, refund_script + + +def taproot_address(output_key_xonly: bytes, network: str) -> str: + return _bech32m_addr(_SEGWIT_HRP.get(network, "bcrt"), 1, output_key_xonly) + + +# ── Tx serialization helpers ────────────────────────────────────────────────── +def _varint(n: int) -> bytes: + if n < 0xFD: + return n.to_bytes(1, "little") + if n <= 0xFFFF: + return b"\xfd" + n.to_bytes(2, "little") + if n <= 0xFFFFFFFF: + return b"\xfe" + n.to_bytes(4, "little") + return b"\xff" + n.to_bytes(8, "little") + + +def _spk_from_address(addr: str) -> bytes: + """Decode a refund destination address to its scriptPubKey (bech32/bech32m).""" + # minimal segwit decode (v0 bech32 / v1 bech32m). Legacy not supported (refund + # addresses should be bech32). Returns scriptPubKey bytes. + hrp_end = addr.rfind("1") + hrp = addr[:hrp_end] + # decode data part + data = [_CH.find(c) for c in addr[hrp_end + 1:]] + if any(d == -1 for d in data): + raise ValueError("bad address char") + witver = data[0] + prog = bytes(_convertbits(data[1:-6], 5, 8, pad=False)) + if witver == 0: + return bytes([0x00, len(prog)]) + prog # OP_0 push + op = 0x50 + witver # OP_1..OP_16 + return bytes([op, len(prog)]) + prog + + +# ── BIP-341 script-path sighash ─────────────────────────────────────────────── +def _script_path_sighash( + version: int, locktime: int, + in_txid: str, in_vout: int, in_value: int, in_spk: bytes, in_sequence: int, + outputs: list, # [(spk, amount)] + tapleaf_hash: bytes, +) -> bytes: + txid_le = bytes.fromhex(in_txid)[::-1] + outpoint = txid_le + in_vout.to_bytes(4, "little") + seq = in_sequence.to_bytes(4, "little") + + sha_prevouts = hashlib.sha256(outpoint).digest() + sha_amounts = hashlib.sha256(in_value.to_bytes(8, "little")).digest() + sha_spks = hashlib.sha256(_varint(len(in_spk)) + in_spk).digest() + sha_seqs = hashlib.sha256(seq).digest() + out_ser = b"".join(amt.to_bytes(8, "little") + _varint(len(spk)) + spk for spk, amt in outputs) + sha_outputs = hashlib.sha256(out_ser).digest() + + spend_type = 0x02 # script-path, no annex + msg = ( + b"\x00" # epoch + + b"\x00" # hash_type SIGHASH_DEFAULT + + version.to_bytes(4, "little") + + locktime.to_bytes(4, "little") + + sha_prevouts + sha_amounts + sha_spks + sha_seqs + + sha_outputs + + bytes([spend_type]) + + (0).to_bytes(4, "little") # input index (single input) + + tapleaf_hash + b"\x00" + b"\xff\xff\xff\xff" # ext: leaf||keyver||codesep + ) + return _tagged("TapSighash", msg) + + +# ── Build + sign the refund transaction ─────────────────────────────────────── +def build_refund_tx( + *, + refund_privkey_hex: str, + claim_public_key_hex: str, + swap_tree: dict, + lockup_address: str, # the address Boltz funded (for the self-check) + lockup_txid: str, + lockup_vout: int, + lockup_value: int, + destination_address: str, # user's on-chain refund address + timeout_block_height: int, + fee_sats: int, + network: str, # REQUIRED — caller passes the swap's recorded network +) -> str: + """ + Returns the signed refund tx hex. RAISES if the reconstructed Taproot address + does not match `lockup_address` (the safety guardrail) — so it never signs + against a mis-reconstructed output. + """ + refund_priv = bytes.fromhex(refund_privkey_hex) + refund_pub = coincurve.PrivateKey(refund_priv).public_key.format(compressed=True).hex() + + claim_script_hex = swap_tree["claimLeaf"]["output"] + refund_script_hex = swap_tree["refundLeaf"]["output"] + + # Boltz may aggregate the internal key in different key orders (fixed order + # vs BIP-327 sorted). The order changes the address, so try each and keep the + # one that reconstructs the ACTUAL funded lockup address. The guardrail below + # still enforces a match, so this can only ever select a correct reconstruction. + recon = None + for order in ("claim_first", "refund_first", "sorted"): + ikey, outkey, parity, control_block, refund_script = reconstruct_taproot( + claim_public_key_hex, refund_pub, claim_script_hex, refund_script_hex, agg_order=order + ) + if taproot_address(outkey, network) == lockup_address: + recon = (ikey, outkey, parity, control_block, refund_script) + logger.info(f"[refund] reconstruction matched with key order: {order}") + break + + # ── SAFETY GUARDRAIL: reconstructed address must equal the funded address ── + if recon is None: + # Recompute the default order purely to report it in the error. + _, outkey_dbg, _, _, _ = reconstruct_taproot( + claim_public_key_hex, refund_pub, claim_script_hex, refund_script_hex, agg_order="claim_first" + ) + recon_addr = taproot_address(outkey_dbg, network) + raise RuntimeError( + "Refund ABORTED: reconstructed Taproot address does not match the " + f"funded lockup address (tried all key orders).\n reconstructed={recon_addr}\n lockup={lockup_address}\n" + "Refusing to sign — the swap tree / keys do not reconstruct the output." + ) + ikey, outkey, parity, control_block, refund_script = recon + + in_spk = bytes.fromhex("5120") + outkey # P2TR scriptPubKey of the lockup + dest_spk = _spk_from_address(destination_address) + out_amount = lockup_value - fee_sats + if out_amount <= 0: + raise ValueError("fee exceeds lockup value") + + version = 2 + sequence = 0xFFFFFFFE # enables nLockTime/CLTV, not final + outputs = [(dest_spk, out_amount)] + + tapleaf_hash = _leaf_hash(refund_script) + sighash = _script_path_sighash( + version, timeout_block_height, + lockup_txid, lockup_vout, lockup_value, in_spk, sequence, + outputs, tapleaf_hash, + ) + + # Schnorr sign (BIP-340) with the refund key. + sig = coincurve.PrivateKey(refund_priv).sign_schnorr(sighash) # 64 bytes, SIGHASH_DEFAULT + + # witness stack: [signature, refund_script, control_block] + witness = ( + _varint(3) + + _varint(len(sig)) + sig + + _varint(len(refund_script)) + refund_script + + _varint(len(control_block)) + control_block + ) + + txid_le = bytes.fromhex(lockup_txid)[::-1] + out_ser = b"".join(amt.to_bytes(8, "little") + _varint(len(spk)) + spk for spk, amt in outputs) + tx = ( + version.to_bytes(4, "little") + + b"\x00\x01" # segwit marker+flag + + _varint(1) + + txid_le + lockup_vout.to_bytes(4, "little") + _varint(0) + sequence.to_bytes(4, "little") + + _varint(len(outputs)) + out_ser + + witness + + timeout_block_height.to_bytes(4, "little") # nLockTime + ) + return tx.hex() + + +# ── Notes ───────────────────────────────────────────────────────────────────── +# • Broadcast only at block height >= timeout_block_height, else CLTV rejects it. +# • sign_schnorr: confirm coincurve's method name/signature in your version. Some +# builds expose PrivateKey.sign_schnorr(msg32); others need the schnorr module. +# If it errors, that's the one call to adjust — the sighash above is the message. +# • The guardrail (address match) is verified-correct against real swap data, so +# if build_refund_tx does NOT raise, the control block + script are right and a +# rejected broadcast would only indicate a signing/serialization issue (safe). \ No newline at end of file diff --git a/boltz_refund_api.py b/boltz_refund_api.py new file mode 100644 index 0000000..99a12b1 --- /dev/null +++ b/boltz_refund_api.py @@ -0,0 +1,415 @@ +""" +boltz_refund_api.py — detect failed/refundable swap-ins and broadcast refunds. + +Ties together: + - persisted swap records (boltz_swaps table / crud) + - Boltz status (GET /v2/swap/submarine/{id}) + - the current chain height (mempool/esplora) + - build_refund_tx() (the verified script-path refund builder) + - broadcasting via mempool_url /api/tx (the path siLNt already uses) + +A swap is REFUNDABLE when: + - it has an on-chain lockup recorded (lockup_txid/vout/value set), AND + - it has NOT completed (status != completed/refunded), AND + - EITHER Boltz reports a failure status (invoice.failedToPay / + transaction.lockupFailed / swap.expired), OR the chain height has reached + the swap's timeout_block_height (script-path refund becomes valid). + +Endpoints (mounted under /siLNt): + GET /api/v1/swap/refundable → list refundable swaps for the user + POST /api/v1/swap/{id}/refund {address} → build + broadcast the refund tx + +Also exposes refund_due_swaps() for an optional background task that auto-refunds +after timeout (mirrors how the old Boltz extension retried refunds periodically). +""" + +import json +from http import HTTPStatus +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from loguru import logger +from pydantic import BaseModel + +from lnbits.core.models import WalletTypeInfo +from lnbits.decorators import require_admin_key +from lnbits.core.crud import get_standalone_payment + +# siLNt-local imports — adjust paths to your layout +from .crud import ( + get_backend_config, + DEFAULT_CONFIG_NETWORK, + get_boltz_swap, + update_boltz_swap, + list_boltz_swaps_by_status, + list_boltz_swaps_for_wallet, + delete_boltz_swap +) +from .boltz_refund import build_refund_tx +from .models import RefundRequest +from .swap_crypto import decrypt_refund_key + +FAILURE_STATES = { + "invoice.failedToPay", + "transaction.lockupFailed", + "swap.expired", +} +TERMINAL_STATES = {"transaction.claimed", "invoice.settled"} # swap succeeded +# Statuses that are terminal/finished — safe to delete from history. +DELETABLE_STATES = {"completed", "refunded", "expired"} + +silnt_refund_router = APIRouter() + + +# ── helpers ─────────────────────────────────────────────────────────────────── +async def _boltz_base(network: str = DEFAULT_CONFIG_NETWORK) -> str: + cfg = await get_backend_config(network) + url = cfg.boltz_url + if not url: + raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, "Boltz URL not configured.") + return url + + +async def _mempool_base(network: str = DEFAULT_CONFIG_NETWORK) -> str: + cfg = await get_backend_config(network) + url = cfg.mempool_url + if not url: + raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, "Mempool URL not configured.") + return url + +async def _invoice_paid(payment_hash: Optional[str]) -> bool: + """ + Authoritative completion check: did the swap's Lightning invoice get paid? + A submarine swap-in completes exactly when Boltz pays our invoice. This does + NOT depend on Boltz still remembering the swap (Boltz 404s completed swaps + after a retention window, which left them stuck as 'funded' forever). + """ + if not payment_hash: + return False + try: + payment = await get_standalone_payment(payment_hash, incoming=True) + if not payment: + logger.info(f"[swap] invoice {payment_hash[:12]}…: no payment row found") + return False + paid = ( + getattr(payment, "success", None) is True + or getattr(payment, "status", "") == "success" + ) + logger.info(f"[swap] invoice {payment_hash[:12]}…: status={getattr(payment,'status','?')} success={getattr(payment,'success','?')} → paid={paid}") + return paid + except Exception as exc: + logger.warning(f"invoice paid-check failed for {payment_hash}: {exc}") + return False + +async def _invoice_failed(payment_hash) -> bool: + if not payment_hash: + return False + try: + p = await get_standalone_payment(payment_hash, incoming=True) + if not p: + return False + return getattr(p, "status", "") == "failed" or getattr(p, "failed", None) is True + except Exception: + return False + +async def _lockup_confirmed(rec) -> bool: + """True only if the recorded lockup tx is confirmed on-chain.""" + if not rec.lockup_txid: + return False + try: + base = await _mempool_base(rec.network or DEFAULT_CONFIG_NETWORK) + async with httpx.AsyncClient(timeout=20) as c: + r = await c.get(f"{base}/api/tx/{rec.lockup_txid}/status") + return r.status_code == 200 and bool(r.json().get("confirmed")) + except Exception: + return False + +async def _boltz_status(swap_id: str, network: str = DEFAULT_CONFIG_NETWORK) -> Optional[str]: + base = await _boltz_base(network) + async with httpx.AsyncClient(timeout=20) as c: + r = await c.get(f"{base}/v2/swap/submarine/{swap_id}") + if r.status_code != 200: + logger.info(f"[boltz] status {swap_id}: HTTP {r.status_code}") + return None + state = r.json().get("status") + logger.info(f"[boltz] status {swap_id}: {state}") + return state + + +async def _chain_height(network: str = DEFAULT_CONFIG_NETWORK) -> int: + base = await _mempool_base(network) + async with httpx.AsyncClient(timeout=20) as c: + r = await c.get(f"{base}/api/blocks/tip/height") + r.raise_for_status() + return int(r.text.strip()) + + +async def _broadcast(tx_hex: str, network: str = DEFAULT_CONFIG_NETWORK) -> str: + base = await _mempool_base(network) + async with httpx.AsyncClient(timeout=30) as c: + r = await c.post(f"{base}/api/tx", content=tx_hex) + if r.status_code != 200: + raise HTTPException(HTTPStatus.BAD_GATEWAY, f"Broadcast rejected: {r.text}") + return r.text.strip() # txid + + +async def _is_refundable(rec, height: int) -> tuple: + """ + Returns (refundable: bool, reason: str). A swap is refundable if it has a + recorded lockup, isn't terminal, and is either failed at Boltz or past timeout. + """ + if rec.status in ("refunded", "completed"): + return False, rec.status + if not (rec.lockup_txid and rec.lockup_vout is not None and rec.lockup_value): + logger.info(f"[refundable?] {rec.id}: NO lockup recorded (txid={rec.lockup_txid} vout={rec.lockup_vout} value={rec.lockup_value})") + return False, "no lockup recorded (nothing was funded on-chain yet)" + + if await _invoice_paid(rec.payment_hash): + logger.info(f"[refundable?] {rec.id}: NOT refundable — invoice paid (swap succeeded)") + return False, "swap already succeeded (invoice paid)" + status = await _boltz_status(rec.id, rec.network or DEFAULT_CONFIG_NETWORK) + logger.info(f"[refundable?] {rec.id}: boltz_status={status} height={height} timeout={rec.timeout_block_height} lockup={rec.lockup_txid}") + if status in TERMINAL_STATES: + return False, "swap already succeeded" + if status in FAILURE_STATES: + return True, f"boltz status {status}" + if rec.timeout_block_height and height >= rec.timeout_block_height: + logger.info(f"[refundable?] {rec.id}: REFUNDABLE via timeout ({height} >= {rec.timeout_block_height})") + return True, f"timeout reached ({height} >= {rec.timeout_block_height})" + blocks_left = (rec.timeout_block_height or 0) - height + logger.info(f"[refundable?] {rec.id}: NOT refundable — not yet timeout, ~{blocks_left} blocks left") + return False, f"not yet refundable (status {status}, ~{blocks_left} blocks to timeout)" + + +# ── endpoints ───────────────────────────────────────────────────────────────── + + +@silnt_refund_router.get("/api/v1/swap/refundable") +async def api_list_refundable(key_info: WalletTypeInfo = Depends(require_admin_key)): + """List the caller's swaps that are currently refundable + why.""" + height = await _chain_height() + out = [] + # Pull candidate swaps in non-terminal states. + for state in ("created", "funded", "failed"): + for rec in await list_boltz_swaps_by_status(state): + if rec.wallet_id != key_info.wallet.id and rec.silnt_wallet_id != key_info.wallet.id: + logger.info(f"[refundable?] {rec.id}: SKIPPED ownership — rec.wallet_id={rec.wallet_id} rec.silnt_wallet_id={rec.silnt_wallet_id} key.wallet.id={key_info.wallet.id}") + continue + logger.info(f"[refundable?] {rec.id}: candidate (status={rec.status}) — checking…") + try: + ok, reason = await _is_refundable(rec, height) + except Exception as exc: + # A Boltz/network hiccup in _is_refundable must NOT hide a + # potentially-refundable swap. Fall back to the local, fund-safe + # signal: lockup recorded + past timeout + not already terminal. + logger.warning(f"refundable check errored for {rec.id}: {exc}") + local_ok = ( + rec.status not in ("refunded", "completed") + and bool(rec.lockup_txid) and rec.lockup_vout is not None and bool(rec.lockup_value) + and rec.timeout_block_height and height >= rec.timeout_block_height + ) + ok, reason = local_ok, "timeout reached (boltz status unavailable)" + if ok: + out.append({ + "swap_id": rec.id, + "amount": rec.lockup_value, + "timeout_block_height": rec.timeout_block_height, + "reason": reason, + }) + return {"refundable": out, "chain_height": height} + + +@silnt_refund_router.post("/api/v1/swap/{swap_id}/refund") +async def api_refund_swap( + swap_id: str, + data: RefundRequest, + key_info: WalletTypeInfo = Depends(require_admin_key), +): + """Build + broadcast a script-path refund for a failed/timed-out swap-in.""" + rec = await get_boltz_swap(swap_id) + if not rec: + raise HTTPException(HTTPStatus.NOT_FOUND, "Swap not found.") + # ownership + if rec.wallet_id != key_info.wallet.id and rec.silnt_wallet_id != key_info.wallet.id: + raise HTTPException(HTTPStatus.FORBIDDEN, "Not your swap.") + + height = await _chain_height(rec.network or DEFAULT_CONFIG_NETWORK) + ok, reason = await _is_refundable(rec, height) + if not ok: + raise HTTPException(HTTPStatus.BAD_REQUEST, f"Not refundable: {reason}") + + # Resolve the refund destination: explicit request address, else the address + # stored when the swap was created. Must be a plain on-chain (non-SP) address. + dest = (data.address or rec.refund_address or "").strip() + if not dest: + raise HTTPException(HTTPStatus.BAD_REQUEST, "No refund address available (none provided or stored).") + if dest.lower().startswith(("sp1", "tsp1")): + raise HTTPException(HTTPStatus.BAD_REQUEST, "Refund address must be a plain on-chain address, not SP.") + + # Fund-critical: never guess the network. A missing network (e.g. a legacy + # row from before the field existed) must fail loudly, not default — a wrong + # network would build a wrong-encoding address for real funds. + if not rec.network: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "This swap record has no network recorded — refusing to build a refund " + "(cannot safely determine the address encoding).", + ) + + try: + tx_hex = build_refund_tx( + refund_privkey_hex=decrypt_refund_key(rec.refund_privkey).strip(), + claim_public_key_hex=rec.claim_public_key, + swap_tree=rec.swap_tree, + lockup_address=rec.address, # the funded address — guardrail checks this + lockup_txid=rec.lockup_txid, + lockup_vout=rec.lockup_vout, + lockup_value=rec.lockup_value, + destination_address=dest, + timeout_block_height=rec.timeout_block_height, + fee_sats=data.fee_sats, + network=rec.network, + ) + except RuntimeError as exc: + # The address-match guardrail or a build error. Do NOT broadcast. + logger.error(f"refund build failed for {swap_id}: {exc}") + raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, f"Could not build refund: {exc}") + + txid = await _broadcast(tx_hex, rec.network or DEFAULT_CONFIG_NETWORK) + rec.status = "refunded" + rec.refund_address = dest + await update_boltz_swap(rec) + logger.info(f"Refund broadcast for swap {swap_id}: {txid}") + return {"success": True, "txid": txid, "swap_id": swap_id, "refunded_to": dest} + +@silnt_refund_router.get("/api/v1/swap/list") +async def api_list_swaps(key_info: WalletTypeInfo = Depends(require_admin_key)): + """All of the caller's swaps (history), newest first, with a deletable flag.""" + height = await _chain_height() + rows = await list_boltz_swaps_for_wallet(key_info.wallet.id, key_info.wallet.id) + out = [] + for rec in rows: + # Reconcile: if a funded swap has actually settled at Boltz, advance it to + # "completed" so it stops showing as active (nothing else updates this). + if rec.status == "funded": + done = await _invoice_paid(rec.payment_hash) + if not done: + boltz_state = await _boltz_status(rec.id, rec.network or DEFAULT_CONFIG_NETWORK) + done = boltz_state in TERMINAL_STATES + if done: + rec.status = "completed" + await update_boltz_swap(rec) + # Derive a display status: mark past-timeout non-terminal swaps as "expired". + status = rec.status + if status not in ("completed", "refunded") and rec.timeout_block_height and height >= rec.timeout_block_height: + status = "expired" if status != "funded" else status + if status not in ("completed", "refunded", "failed", "expired"): + invoice_failed = await _invoice_failed(rec.payment_hash) # see helper + lockup_confirmed = await _lockup_confirmed(rec) # see helper + if invoice_failed and not lockup_confirmed: + rec.status = "failed" + await update_boltz_swap(rec) + status = "failed" + deletable = (status in DELETABLE_STATES) or (not rec.lockup_txid) + not_expired = not (rec.timeout_block_height and height >= rec.timeout_block_height) + fundable = ( + status == "created" + and not rec.lockup_txid + and not_expired + and bool(rec.address) + and bool(rec.expected_amount) + ) + out.append({ + "swap_id": rec.id, + "status": status, + "amount": rec.expected_amount or rec.lockup_value, + "timeout_block_height": rec.timeout_block_height, + "silnt_wallet_id": rec.silnt_wallet_id, + "payment_hash": rec.payment_hash, + "deletable": deletable, + "fundable": fundable, + # funding details (so the client can resume funding from history): + "address": rec.address if fundable else None, + "expected_amount": rec.expected_amount if fundable else None, + }) + return {"swaps": out, "chain_height": height} + + +@silnt_refund_router.delete("/api/v1/swap/{swap_id}") +async def api_delete_swap( + swap_id: str, + key_info: WalletTypeInfo = Depends(require_admin_key), +): + """Delete a finished swap (completed/refunded/expired) from history.""" + rec = await get_boltz_swap(swap_id) + if not rec: + raise HTTPException(HTTPStatus.NOT_FOUND, "Swap not found.") + if rec.wallet_id != key_info.wallet.id and rec.silnt_wallet_id != key_info.wallet.id: + raise HTTPException(HTTPStatus.FORBIDDEN, "Not your swap.") + + # Only allow deleting genuinely finished swaps — never an active/refundable one, + # so a user can't accidentally discard a swap whose funds are still recoverable. + height = await _chain_height(rec.network or DEFAULT_CONFIG_NETWORK) + status = rec.status + if status not in ("completed", "refunded"): + # is it expired (past timeout and never refunded)? Block deletion if it's + # still refundable — losing the record would lose the refund key. + refundable, _ = await _is_refundable(rec, height) + if refundable: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "This swap is still refundable — refund it before deleting, or you'll lose the ability to recover the funds.", + ) + # not refundable and not completed: only deletable if it never got funded + # (no lockup) — i.e. nothing at stake. + if rec.lockup_txid: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "Swap has on-chain funds and isn't completed or refunded yet; can't delete.", + ) + + await delete_boltz_swap(swap_id) + return {"success": True, "deleted": swap_id} + +# ── optional: background auto-refund (call from a periodic task) ─────────────── +async def refund_due_swaps(default_fee_sats: int = 300) -> list: + """ + Auto-refund swaps that are refundable AND have a refund_address on record. + Returns a list of {swap_id, txid|error}. Wire to a periodic task if you want + the old extension's "retry every N minutes" behavior. Swaps without a stored + refund_address are skipped (a destination is required). + """ + results = [] + height = await _chain_height() + for state in ("created", "funded", "failed"): + for rec in await list_boltz_swaps_by_status(state): + ok, _ = await _is_refundable(rec, height) + if not ok or not rec.refund_address: + continue + if not rec.network: + # Never guess network for a fund-critical refund; skip legacy rows. + logger.warning(f"auto-refund skipped for {rec.id}: no network recorded") + continue + try: + tx_hex = build_refund_tx( + refund_privkey_hex=decrypt_refund_key(rec.refund_privkey), + claim_public_key_hex=rec.claim_public_key, + swap_tree=rec.swap_tree, + lockup_address=rec.address, + lockup_txid=rec.lockup_txid, + lockup_vout=rec.lockup_vout, + lockup_value=rec.lockup_value, + destination_address=rec.refund_address, + timeout_block_height=rec.timeout_block_height, + fee_sats=default_fee_sats, + network=rec.network, + ) + txid = await _broadcast(tx_hex, rec.network) + rec.status = "refunded" + await update_boltz_swap(rec) + results.append({"swap_id": rec.id, "txid": txid}) + except Exception as exc: + logger.warning(f"auto-refund failed for {rec.id}: {exc}") + results.append({"swap_id": rec.id, "error": str(exc)}) + return results \ No newline at end of file diff --git a/boltz_swap.py b/boltz_swap.py new file mode 100644 index 0000000..fd2f594 --- /dev/null +++ b/boltz_swap.py @@ -0,0 +1,275 @@ +""" +boltz_swap.py — add to the siLNt extension (Boltz v2 submarine swap, SP → Lightning). + +This is the OPTION A happy-path swap-in: it replaces the dead v1 LNbits Boltz +extension (which can't talk to the v2-only regtest/mainnet Boltz backend). + +Flow (chain → lightning / "submarine swap"): + 1. Mint a BOLT11 invoice on the user's LNbits wallet for the amount to receive. + 2. Generate a fresh refund keypair (secp256k1). Store the refund PRIVKEY so a + future refund tx can be built (refund tx construction itself is NOT in this + happy-path module — see the NOT-REFUND-SAFE note below). + 3. POST {boltz}/v2/swap/submarine { invoice, to:'BTC', from:'BTC', + refundPublicKey } → Boltz returns the on-chain lockup `address` + + `expectedAmount` + `swapTree`/`claimPublicKey`/`timeoutBlockHeight`. + 4. Return address + expectedAmount to the client; the user funds it from SP + UTXOs via the normal Send flow. + 5. Boltz sees the on-chain payment and pays the invoice; sats land in the + LNbits wallet. (Cooperative claim at `transaction.claim.pending` is OPTIONAL + for swap-in — Boltz claims via script path on interval if we don't help, so + the happy path does not need Musig2.) + +⚠️ NOT REFUND-SAFE YET: if a swap-in FAILS after the user has sent on-chain, a +refund requires constructing a Taproot refund transaction with the stored refund +key (Musig2 cooperative or script-path). That shared Taproot/Musig2 layer is the +deferred next phase (it is also what Lightning→SP claim needs). For now we store +the refund key + swap tree so a refund CAN be built later, and we surface the +risk to the user in the UI. Test on regtest only until refunds are implemented. + +Config: set SILNT boltz endpoint in the extension settings/env, e.g. + BOLTZ_API_URL = http://127.0.0.1:9001 (regtest: boltz-backend-nginx) + (mainnet: https://api.boltz.exchange) +""" + +import json +import secrets +from http import HTTPStatus +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query +from loguru import logger +from pydantic import BaseModel + +import coincurve + +from lnbits.core.services import create_invoice +from lnbits.decorators import require_admin_key +from lnbits.core.models import WalletTypeInfo +from .crud import ( + get_backend_config, + DEFAULT_CONFIG_NETWORK, + get_boltz_swap, + create_boltz_swap, + update_boltz_swap + ) # siLNt's existing config accessor +from .models import CreateSwapInRequest, SwapInResponse, BoltzSwapRecord, FundedRequest +from .swap_crypto import encrypt_refund_key + +# ── Config ──────────────────────────────────────────────────────────────────── +# boltz_url is stored in the per-network siLNt backend config (same store as +# blindbit_url / mempool_url), editable in the Thrilla Admin screen. Read it the +# same way the scanner reads blindbit_url, keyed by the swap's network. +# +# regtest → http://127.0.0.1:9001 (boltz-backend-nginx) +# mainnet → https://api.boltz.exchange +async def _boltz_url(network: str = DEFAULT_CONFIG_NETWORK) -> str: + + cfg = await get_backend_config(network) + url = cfg.boltz_url + if not url: + raise HTTPException( + HTTPStatus.SERVICE_UNAVAILABLE, + "Swaps are not configured. Set the Boltz API URL in Admin settings.", + ) + return url + + +async def _boltz_get(path: str, network: str = DEFAULT_CONFIG_NETWORK) -> dict: + base = await _boltz_url(network) + async with httpx.AsyncClient(timeout=30) as client: + r = await client.get(f"{base}{path}") + r.raise_for_status() + return r.json() + + +async def _boltz_post(path: str, body: dict, network: str = DEFAULT_CONFIG_NETWORK) -> dict: + base = await _boltz_url(network) + async with httpx.AsyncClient(timeout=30) as client: + r = await client.post(f"{base}{path}", json=body) + if r.status_code >= 400: + # Surface Boltz's error message + try: + detail = r.json().get("error", r.text) + except Exception: + detail = r.text + raise HTTPException(HTTPStatus.BAD_GATEWAY, f"Boltz: {detail}") + return r.json() + + +silnt_boltz_router = APIRouter() + + +@silnt_boltz_router.get("/api/v1/swap/limits") +async def api_swap_limits( + network: str = Query(DEFAULT_CONFIG_NETWORK), + key_info: WalletTypeInfo = Depends(require_admin_key), +): + """Boltz submarine pair limits/fees (min/max), for client-side validation.""" + try: + pairs = await _boltz_get("/v2/swap/submarine", network) + # shape: { "BTC": { "BTC": { "limits": {"minimal":..,"maximal":..}, "fees": {...} } } } + btc = pairs.get("BTC", {}).get("BTC", {}) + limits = btc.get("limits", {}) + fees = btc.get("fees", {}) + return { + "min": limits.get("minimal"), + "max": limits.get("maximal"), + "fees": fees, + } + except HTTPException: + raise + except Exception as exc: + raise HTTPException(HTTPStatus.BAD_GATEWAY, f"Could not fetch Boltz limits: {exc}") + + +@silnt_boltz_router.post("/api/v1/swap/in", response_model=SwapInResponse) +async def api_create_swap_in( + data: CreateSwapInRequest, + key_info: WalletTypeInfo = Depends(require_admin_key), +) -> SwapInResponse: + """ + Create a Boltz v2 submarine swap (chain → lightning). Mints the LN invoice, + generates a refund key, calls Boltz, returns the lockup address + amount. + """ + # 1. Mint the BOLT11 invoice on the user's LNbits wallet. + try: + payment = await create_invoice( + wallet_id=data.wallet_id, + amount=data.amount, + memo=f"Thrilla swap-in {data.amount} sats", + extra={"tag": "silnt_swap"}, + expiry=60 * 60, # 1h to fund the on-chain side + ) + invoice = payment.bolt11 + except Exception as exc: + logger.error(f"swap-in invoice creation failed: {exc}") + raise HTTPException(HTTPStatus.BAD_REQUEST, f"Could not create invoice: {exc}") + + # 2. Fresh refund keypair (kept so a refund tx CAN be built later). + refund_secret = secrets.token_bytes(32) + refund_pub = coincurve.PublicKey.from_secret(refund_secret).format(compressed=True).hex() + + # 3. Create the submarine swap on Boltz v2. + try: + swap = await _boltz_post( + "/v2/swap/submarine", + { + "invoice": invoice, + "to": "BTC", + "from": "BTC", + "refundPublicKey": refund_pub, + }, + data.network, + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"swap-in boltz create failed: {exc}") + raise HTTPException(HTTPStatus.BAD_GATEWAY, f"Boltz create failed: {exc}") + + swap_id = swap.get("id") + address = swap.get("address") + expected = swap.get("expectedAmount") + if not (swap_id and address and expected): + raise HTTPException(HTTPStatus.BAD_GATEWAY, "Boltz response missing swap fields") + + # 4. PERSIST refund material (durable — a refund may be needed later/after restart). + rec = BoltzSwapRecord( + id=swap_id, + wallet_id=data.wallet_id, + silnt_wallet_id=data.silnt_wallet_id, + network=data.network, + status="created", + refund_privkey=encrypt_refund_key(refund_secret.hex()), + refund_public_key=refund_pub, + claim_public_key=swap.get("claimPublicKey"), + swap_tree=swap.get("swapTree"), + timeout_block_height=swap.get("timeoutBlockHeight"), + address=address, # lockup address (guardrail checks this) + expected_amount=int(expected), + invoice=invoice, + payment_hash=payment.payment_hash, + refund_address=data.refund_address, # where a failed-swap refund goes + ) + await create_boltz_swap(rec) + return SwapInResponse( + swap_id=swap_id, + address=address, + expected_amount=int(expected), + timeout_block_height=swap.get("timeoutBlockHeight"), + payment_hash=payment.payment_hash + ) + + +@silnt_boltz_router.get("/api/v1/swap/in/{swap_id}") +async def api_swap_in_status( + swap_id: str, + key_info: WalletTypeInfo = Depends(require_admin_key), +): + """Poll a swap's status from Boltz (client can also use the WS directly).""" + try: + rec = await get_boltz_swap(swap_id) + network = rec.network if rec and rec.network else DEFAULT_CONFIG_NETWORK + status = await _boltz_get(f"/v2/swap/submarine/{swap_id}", network) + return status + except HTTPException: + raise + except Exception as exc: + raise HTTPException(HTTPStatus.BAD_GATEWAY, f"Could not fetch status: {exc}") + # If Boltz reports the swap is done (it claimed the lockup / invoice settled), + # advance our record to "completed" so the UI stops showing it as active. + boltz_state = status.get("status") if isinstance(status, dict) else None + if boltz_state in ("transaction.claimed", "invoice.settled"): + rec = await get_boltz_swap(swap_id) + if rec and rec.status not in ("completed", "refunded"): + rec.status = "completed" + await update_boltz_swap(rec) + return status + +@silnt_boltz_router.post("/api/v1/swap/in/{swap_id}/funded") +async def api_swap_in_funded( + swap_id: str, + data: FundedRequest, + key_info: WalletTypeInfo = Depends(require_admin_key), +): + """ + Record the on-chain lockup outpoint after the SP send broadcasts. REQUIRED for + a refund to be buildable later. The client passes only the funding txid; we + resolve which vout pays the lockup address (and its value) by fetching the tx + from the mempool/esplora endpoint — so the client doesn't have to guess the vout. + """ + rec = await get_boltz_swap(swap_id) + if not rec: + raise HTTPException(HTTPStatus.NOT_FOUND, "Swap not found.") + if rec.wallet_id != key_info.wallet.id and rec.silnt_wallet_id != key_info.wallet.id: + raise HTTPException(HTTPStatus.FORBIDDEN, "Not your swap.") + + # Fetch the tx and find the output paying the lockup address. + cfg = await get_backend_config(rec.network or DEFAULT_CONFIG_NETWORK) + mempool = cfg.mempool_url + if not mempool: + raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, "Mempool URL not configured.") + async with httpx.AsyncClient(timeout=20) as c: + r = await c.get(f"{mempool}/api/tx/{data.lockup_txid}") + if r.status_code != 200: + raise HTTPException(HTTPStatus.BAD_GATEWAY, f"Could not fetch funding tx: {r.status_code}") + tx = r.json() + + vout = None + value = None + for i, o in enumerate(tx.get("vout", [])): + if o.get("scriptpubkey_address") == rec.address: + vout = i + value = o.get("value") + break + if vout is None: + raise HTTPException(HTTPStatus.BAD_REQUEST, + "Funding tx has no output paying the lockup address.") + + rec.lockup_txid = data.lockup_txid + rec.lockup_vout = vout + rec.lockup_value = int(value) + rec.status = "funded" + await update_boltz_swap(rec) + return {"success": True, "swap_id": swap_id, "lockup_vout": vout, "lockup_value": int(value)} \ No newline at end of file diff --git a/config.json b/config.json index ee20315..e502f18 100644 --- a/config.json +++ b/config.json @@ -1,33 +1,15 @@ { - "name": "Build your own!", - "short_description": "Extension building guide", - "tile": "/example/static/bitcoin-extension.png", - "min_lnbits_version": "1.0.0", - "donate": "donate@legend.lnbits.com", + "name": "BitSiLNt", + "short_description": "Silent Payments OnChain Wallet", + "tile": "/siLNt/static/bitcoin-wallet.webp", + "min_lnbits_version": "v1.4.1", + "donate": "satoshi@bitaurus.net", "contributors": [ { - "name": "Ben Arc", - "uri": "mailto:ben@lnbits.com", + "name": "Ponthief", + "uri": "mailto:ponthief@bitaurus.net", "role": "Developer" - }, - { - "name": "DNI", - "uri": "https://github.com/dni", - "role": "Developer" - } - ], - "images": [ - { - "uri": "https://raw.githubusercontent.com/lnbits/example/main/static/1.png" - }, - { - "uri": "https://raw.githubusercontent.com/lnbits/example/main/static/2.png" - }, - { - "uri": "https://raw.githubusercontent.com/lnbits/example/main/static/3.png" } ], - "description_md": "https://raw.githubusercontent.com/lnbits/example/main/description.md", - "terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/example/main/toc.md", "license": "MIT" } diff --git a/crud.py b/crud.py index 608db57..cd5c068 100644 --- a/crud.py +++ b/crud.py @@ -1,21 +1,2716 @@ -# crud.py is for communication with your extensions database - -# add your dependencies here -# from .models import createExample, Example +import json +import time +import secrets +import os +import hashlib +import hmac +from typing import Optional, Tuple, List from lnbits.db import Database +from lnbits.helpers import urlsafe_short_hash +from .models import ( + Config, + BackendConfig, + WalletAccount, + UTXORecord, + WalletAddress, + CloudflareConfig, + NtfyConfig, + UpdateUtxoLabel, + TrustedDevice, + UserPrefs, + AdminAlert, + Bip353Request, + BoltzSwapRecord +) + +from .models import PayjoinDescriptor, PayjoinRequest, PayjoinContact +from embit.descriptor import Descriptor, Key +from embit.descriptor.arguments import AllowedDerivation +from embit.networks import NETWORKS +from datetime import datetime, timedelta, timezone + +db = Database("ext_silnt") + +# Singleton row ID for the global blindbit config +DEFAULT_CONFIG_NETWORK = "signet" +CF_CONFIG_ID = "cloudflare_config" +NTFY_CONFIG_ID = "ntfy_config" +BIP352_CHANGE_LABEL_INDEX = 1 + +async def create_silnt_wallet(wallet: WalletAccount) -> WalletAccount: + await db.insert("silnt.wallets", wallet) + return wallet + + +async def get_silnt_wallet(wallet_id: str) -> Optional[WalletAccount]: + return await db.fetchone( + "SELECT * FROM silnt.wallets WHERE id = :id", + {"id": wallet_id}, + WalletAccount, + ) + + +async def get_silnt_wallets( + user: str, network: Optional[str] = None +) -> list[WalletAccount]: + if network: + return await db.fetchall( + 'SELECT * FROM silnt.wallets WHERE "user" = :user AND network = :network ORDER BY network, title', + {"user": user, "network": network}, + WalletAccount, + ) + return await db.fetchall( + 'SELECT * FROM silnt.wallets WHERE "user" = :user ORDER BY network, title', + {"user": user}, + WalletAccount, + ) + + +async def delete_silnt_wallet(wallet_id: str) -> None: + await db.execute( + "DELETE FROM silnt.wallets WHERE id = :id", + {"id": wallet_id}, + ) + + +async def delete_utxos_for_wallet(wallet_id: str) -> None: + await db.execute( + "DELETE FROM silnt.utxos WHERE wallet_id = :wallet_id", + {"wallet_id": wallet_id}, + ) + + +async def get_sp_address(wallet_id: str) -> str: + return await db.fetchall( + """ + SELECT sp_address FROM silnt.wallets WHERE id = :wallet_id + """, + {"wallet_id": wallet_id}, + str, + ) + + +async def get_hr_address(wallet_id: str) -> str: + return await db.fetchall( + """ + SELECT hr_address FROM silnt.wallets WHERE id = :wallet_id + """, + {"wallet_id": wallet_id}, + str, + ) + + +async def update_hr_address(wallet_id: str, hr_address: str) -> Optional[WalletAccount]: + await db.execute( + "UPDATE silnt.wallets SET hr_address = :hra WHERE id = :wid", + {"hra": hr_address, "wid": wallet_id}, + ) + return await db.fetchone( + "SELECT * FROM silnt.wallets WHERE id = :wid", + {"wid": wallet_id}, + WalletAccount, + ) + + +async def update_last_height( + wallet_id: str, last_height: int +) -> Optional[WalletAccount]: + await db.execute( + "UPDATE silnt.wallets SET last_height = :lh WHERE id = :wid", + {"lh": last_height, "wid": wallet_id}, + ) + return await db.fetchone( + "SELECT * FROM silnt.wallets WHERE id = :wid", + {"wid": wallet_id}, + WalletAccount, + ) + + +async def update_balance(wallet_id: str, balance: int) -> Optional[WalletAccount]: + await db.execute( + "UPDATE silnt.wallets SET balance = :bal WHERE id = :wid", + {"bal": balance, "wid": wallet_id}, + ) + return await db.fetchone( + "SELECT * FROM silnt.wallets WHERE id = :wid", + {"wid": wallet_id}, + WalletAccount, + ) + + +async def update_title(wallet_id: str, title: str) -> Optional[WalletAccount]: + await db.execute( + "UPDATE silnt.wallets SET title = :ttl WHERE id = :wid", + {"ttl": title, "wid": wallet_id}, + ) + return await db.fetchone( + "SELECT * FROM silnt.wallets WHERE id = :wid", + {"wid": wallet_id}, + WalletAccount, + ) + + +async def get_utxos_for_wallet(wallet_id: str) -> list[UTXORecord]: + return await db.fetchall( + "SELECT * FROM silnt.utxos WHERE wallet_id = :wallet_id ORDER BY timestamp DESC", + {"wallet_id": wallet_id}, + UTXORecord, + ) + + +async def insert_utxos_for_wallet(wallet_id: str, utxos: list) -> None: + for utxo in utxos: + row = utxo.to_db_row(wallet_id) + # Tolerate rows from a to_db_row() that predates the label_index column + # (avoids a missing-bind-parameter error if scan.py isn't updated yet). + row.setdefault("label_index", None) + await db.execute( + """INSERT INTO silnt.utxos + (txid, vout, amount, priv_key_tweak, pub_key, utxo_state, timestamp, wallet_id, label, label_index) + VALUES + (:txid, :vout, :amount, :priv_key_tweak, :pub_key, :utxo_state, :timestamp, :wallet_id, :label, :label_index) + ON CONFLICT (txid, vout, wallet_id) DO UPDATE SET + amount = EXCLUDED.amount, + priv_key_tweak = EXCLUDED.priv_key_tweak, + pub_key = EXCLUDED.pub_key, + utxo_state = CASE + WHEN silnt.utxos.utxo_state IN ('spent', 'unconfirmed_spent') + THEN silnt.utxos.utxo_state + ELSE EXCLUDED.utxo_state + END, + label = COALESCE(silnt.utxos.label, EXCLUDED.label), + label_index = COALESCE(silnt.utxos.label_index, EXCLUDED.label_index) + """, + row, + ) + + +async def update_unconfirmed_utxo(wallet_id: str, txid: str): + await db.execute( + "UPDATE silnt.utxos SET utxo_state = 'unconfirmed_spent' WHERE txid = :txid AND wallet_id = :wallet_id", + {"txid": txid, "wallet_id": wallet_id}, + ) + + +async def get_wallet_addresses(wallet_id: str) -> list: + return await db.fetchall( + "SELECT * FROM silnt.wallet_addresses WHERE wallet_id = :wallet_id ORDER BY label_index ASC", + {"wallet_id": wallet_id}, + ) + +async def get_wallet_address(id: str) -> Optional[WalletAddress]: + return await db.fetchone( + "SELECT * FROM silnt.wallet_addresses WHERE id = :id", + {"id": id}, + WalletAddress + ) + +async def count_wallet_addresses(wallet_id: str) -> int: + row = await db.fetchone( + "SELECT COUNT(*) as cnt FROM silnt.wallet_addresses WHERE wallet_id = :wallet_id", + {"wallet_id": wallet_id}, + ) + return row["cnt"] if row else 0 + + +async def insert_wallet_address( + wallet_id: str, sp_address: str, label_index: int, address_id +) -> None: + await db.execute( + """INSERT INTO silnt.wallet_addresses (id, wallet_id, sp_address, label_index, created_at) + VALUES (:id, :wallet_id, :sp_address, :label_index, :created_at)""", + { + "id": address_id, + "wallet_id": wallet_id, + "sp_address": sp_address, + "label_index": label_index, + "created_at": int(time.time()), + }, + ) + + +async def delete_wallet_label_addresses(wallet_id: str) -> None: + await db.execute( + "DELETE FROM silnt.wallet_addresses WHERE wallet_id = :wallet_id", + {"wallet_id": wallet_id}, + ) + + +async def delete_wallet_label_address(address_id: str, wallet_id: str) -> None: + await db.execute( + "DELETE FROM silnt.wallet_addresses WHERE id = :id AND wallet_id = :wallet_id", + {"id": address_id, "wallet_id": wallet_id}, + ) + + +# ── Global admin-only backend config ─────────────────────────────────────── + + +async def get_backend_config(network: str) -> BackendConfig: + row = await db.fetchone( + "SELECT json_data FROM silnt.backend_config WHERE id = :id", + {"id": network}, + ) + if not row: + return BackendConfig() + return BackendConfig(**json.loads(row["json_data"])) + + +async def update_backend_config(config: BackendConfig, network: str) -> BackendConfig: + json_data = config.json() + existing = await db.fetchone( + "SELECT id FROM silnt.backend_config WHERE id = :id", + {"id": network}, + ) + if existing: + await db.execute( + "UPDATE silnt.backend_config SET json_data = :json_data WHERE id = :id", + {"json_data": json_data, "id": network}, + ) + else: + await db.execute( + "INSERT INTO silnt.backend_config (id, json_data) VALUES (:id, :json_data)", + {"id": network, "json_data": json_data}, + ) + return config + + +async def get_cloudflare_config() -> CloudflareConfig: + row = await db.fetchone( + "SELECT json_data FROM silnt.backend_config WHERE id = :id", + {"id": CF_CONFIG_ID}, + ) + cfg = CloudflareConfig(**json.loads(row["json_data"])) if row else CloudflareConfig() + # BitMail/DNS domain is deployment config, not an admin-editable field. + # Source it from SILNT_BITMAIL_DOMAIN when set; otherwise keep whatever is + # stored (back-compat). Strip a leading dot in case someone reuses the + # cookie-domain form (".thrilla.me" → "thrilla.me"). + env_domain = os.environ.get("SILNT_BITMAIL_DOMAIN", "").strip().lstrip(".") + if env_domain: + cfg.domain = env_domain + return cfg + + +async def update_cloudflare_config(config: CloudflareConfig) -> CloudflareConfig: + # Domain is not admin-editable — force it from the env var (or keep the + # currently-effective value), ignoring whatever the client sent. + env_domain = os.environ.get("SILNT_BITMAIL_DOMAIN", "").strip().lstrip(".") + if env_domain: + config.domain = env_domain + else: + # No env override: preserve the existing stored domain rather than let + # the client change it. + existing = await db.fetchone( + "SELECT json_data FROM silnt.backend_config WHERE id = :id", + {"id": CF_CONFIG_ID}, + ) + if existing: + try: + config.domain = CloudflareConfig(**json.loads(existing["json_data"])).domain + except Exception: + pass + json_data = config.json() + existing = await db.fetchone( + "SELECT id FROM silnt.backend_config WHERE id = :id", + {"id": CF_CONFIG_ID}, + ) + if existing: + await db.execute( + "UPDATE silnt.backend_config SET json_data = :json_data WHERE id = :id", + {"json_data": json_data, "id": CF_CONFIG_ID}, + ) + else: + await db.execute( + "INSERT INTO silnt.backend_config (id, json_data) VALUES (:id, :json_data)", + {"id": CF_CONFIG_ID, "json_data": json_data}, + ) + return config + + + +async def get_ntfy_config() -> NtfyConfig: + row = await db.fetchone( + "SELECT json_data FROM silnt.backend_config WHERE id = :id", + {"id": NTFY_CONFIG_ID}, + ) + return NtfyConfig(**json.loads(row["json_data"])) if row else NtfyConfig() + + +async def update_ntfy_config(config: NtfyConfig) -> NtfyConfig: + json_data = config.json() + existing = await db.fetchone( + "SELECT id FROM silnt.backend_config WHERE id = :id", + {"id": NTFY_CONFIG_ID}, + ) + if existing: + await db.execute( + "UPDATE silnt.backend_config SET json_data = :json_data WHERE id = :id", + {"json_data": json_data, "id": NTFY_CONFIG_ID}, + ) + else: + await db.execute( + "INSERT INTO silnt.backend_config (id, json_data) VALUES (:id, :json_data)", + {"id": NTFY_CONFIG_ID, "json_data": json_data}, + ) + return config + + +async def send_ntfy_notification( + title: str, message: str, tags: Optional[list] = None, priority: Optional[str] = None +) -> dict: + """ + Publish a notification to all configured ntfy topics. Best-effort: never + raises — returns a small summary so callers/endpoints can report status. + Does nothing (and reports disabled) when ntfy is off or misconfigured. + """ + import httpx + + cfg = await get_ntfy_config() + if not cfg.enabled: + return {"sent": 0, "skipped": "disabled"} + server = (cfg.server_url or "https://ntfy.sh").rstrip("/") + topics = [t.strip() for t in (cfg.topics or []) if t and t.strip()] + if not server or not topics: + return {"sent": 0, "skipped": "not_configured"} + + # The ntfy Title is sent as an HTTP header, which must be ASCII. A non-ASCII + # char (e.g. an emoji) would make httpx raise and drop the notification, so + # coerce it to ASCII here (emoji belong in Tags/'body, not the title header). + safe_title = (title or "").encode("ascii", "ignore").decode("ascii").strip() or "siLNt" + headers = {"Title": safe_title, "Priority": priority or cfg.priority or "default"} + if tags: + headers["Tags"] = ",".join(tags) + # Auth: HTTP Basic (username/password) for self-hosted servers that require + # it; fall back to a bearer access token if that's how the server is set up. + auth = None + if cfg.username: + auth = (cfg.username, cfg.password or "") + elif cfg.access_token: + headers["Authorization"] = f"Bearer {cfg.access_token}" + + sent, errors = 0, [] + async with httpx.AsyncClient(timeout=10.0) as c: + for topic in topics: + try: + r = await c.post( + f"{server}/{topic}", + content=message.encode("utf-8"), + headers=headers, + auth=auth, + ) + if r.status_code < 300: + sent += 1 + else: + errors.append(f"{topic}: HTTP {r.status_code}") + except Exception as e: + errors.append(f"{topic}: {e}") + return {"sent": sent, "topics": len(topics), "errors": errors} + + +HEALTH_STATE_ID = "service_health_state" + + +async def notify_service_health_change(service: str, is_up: bool, detail: str = "") -> None: + """ + Fire an ntfy notification ONLY when a service's up/down state changes + (up→down or down→up), so a persistently-down service doesn't spam on every + health poll. Last-known state is stored as a small JSON blob in the config + table. Best-effort — never raises. + """ + try: + from loguru import logger as _logger + _logger.warning(f"[health] {service} reported {'UP' if is_up else 'DOWN'} (detail={detail!r})") + except Exception: + pass + # Per-service row id, so BlindBit and Fulcrum checks (which run as separate, + # near-concurrent requests) never do a racy read-modify-write on ONE shared + # row and clobber each other's state — which silently swallows a service's + # up/down transitions (the cause of Fulcrum's recovery ntfy going missing). + state_id = f"{HEALTH_STATE_ID}:{service}" + try: + row = await db.fetchone( + "SELECT json_data FROM silnt.backend_config WHERE id = :id", + {"id": state_id}, + ) + prev = json.loads(row["json_data"]).get("up") if row else None + except Exception: + prev = None + + if prev is not None and prev == is_up: + return # no change → no notification + + # Persist the new state first (so a send failure doesn't cause repeat sends). + try: + payload = json.dumps({"up": is_up}) + exists = await db.fetchone( + "SELECT id FROM silnt.backend_config WHERE id = :id", {"id": state_id} + ) + if exists: + await db.execute( + "UPDATE silnt.backend_config SET json_data = :j WHERE id = :id", + {"j": payload, "id": state_id}, + ) + else: + await db.execute( + "INSERT INTO silnt.backend_config (id, json_data) VALUES (:id, :j)", + {"id": state_id, "j": payload}, + ) + except Exception: + pass + + # First-ever observation (prev is None): stay quiet if UP (normal startup), + # but DO alert if it's already DOWN. + if prev is None and is_up: + return + + if is_up: + await send_ntfy_notification( + title=f"{service} recovered", + message=f"{service} is reachable again." + (f" {detail}" if detail else ""), + tags=["white_check_mark"], + priority="default", + ) + else: + await send_ntfy_notification( + title=f"{service} is DOWN", + message=f"{service} is not reachable." + (f" {detail}" if detail else ""), + tags=["rotating_light"], + priority="high", + ) + + +async def reset_service_health_state() -> None: + """Delete the stored service health-state row so up/down tracking restarts + clean (the next health check is treated as a first observation). Called when + ntfy config is saved, so stale state can't permanently suppress alerts.""" + await db.execute( + "DELETE FROM silnt.backend_config WHERE id = :id", {"id": HEALTH_STATE_ID} + ) + +async def count_silnt_wallets(user: str, network: Optional[str] = None) -> int: + if network: + row = await db.fetchone( + 'SELECT COUNT(*) AS c FROM silnt.wallets WHERE "user" = :user AND network = :network', + {"user": user, "network": network}, + ) + else: + row = await db.fetchone( + 'SELECT COUNT(*) AS c FROM silnt.wallets WHERE "user" = :user', + {"user": user}, + ) + return row["c"] if row else 0 + +async def update_utxo_label_by_txid( + txid: str, label: Optional[str], wallet_id: Optional[str] +) -> int: + """ + Set or clear the label on UTXO(s) matching the given txid. + Returns the number of rows updated. Empty string label is treated as NULL. + Optionally scoped to a specific wallet_id for safety. + """ + label_value = (label or "").strip() or None + if wallet_id: + result = await db.execute( + """UPDATE silnt.utxos SET label = :label + WHERE txid = :txid AND wallet_id = :wallet_id""", + {"label": label_value, "txid": txid, "wallet_id": wallet_id}, + ) + else: + result = await db.execute( + "UPDATE silnt.utxos SET label = :label WHERE txid = :txid", + {"label": label_value, "txid": txid}, + ) + return getattr(result, "rowcount", 0) or 0 + + +async def get_utxos_by_txid(txid: str) -> list: + """Return all UTXO rows for a given txid (may have multiple vouts).""" + rows = await db.fetchall( + "SELECT * FROM silnt.utxos WHERE txid = :txid", {"txid": txid} + ) + return [UTXORecord(**dict(r)) for r in rows] + +async def get_next_label_index(wallet_id: str) -> int: + """ + Return the LOWEST free label index >= 2 for this wallet (m=0 is the base + address, m=1 is the BIP-352 change label, both reserved). Using the lowest + free index — rather than MAX+1 — means deleting a labeled address (e.g. m=2) + frees that slot, so the next generated address reuses it instead of skipping + to m=4 and leaving a permanent hole (and drifting past the wallet's small + labeled-address range). + """ + rows = await db.fetchall( + "SELECT label_index FROM silnt.wallet_addresses WHERE wallet_id = :wid", + {"wid": wallet_id}, + ) + used = {int(r["label_index"]) for r in rows if r["label_index"] is not None} + idx = BIP352_CHANGE_LABEL_INDEX + 1 # start at 2 + while idx in used: + idx += 1 + return idx + + +async def address_exists(wallet_id: str, sp_address: str) -> bool: + row = await db.fetchone( + """SELECT 1 FROM silnt.wallet_addresses + WHERE wallet_id = :wid AND sp_address = :addr""", + {"wid": wallet_id, "addr": sp_address}, + ) + return row is not None + +async def label_index_taken(wallet_id: str, label_index: int) -> bool: + row = await db.fetchone( + """SELECT 1 FROM silnt.wallet_addresses + WHERE wallet_id = :wid AND label_index = :idx""", + {"wid": wallet_id, "idx": label_index}, + ) + return row is not None + + +async def ensure_labeled_address_row( + wallet_id: str, sp_address: str, label_index: int +) -> bool: + """ + Idempotently ensure a wallet_addresses row exists for a labeled address that + a scan actually found funds on. Used to restore labeled-address rows after a + seed reimport WITHOUT over-creating: only labels the wallet genuinely used + (received on) come back, so count_wallet_addresses stays accurate and the + per-wallet address limit isn't falsely tripped. Returns True if a new row was + inserted. Never touches the change label (m=1) or base (m=0). + + NOTE: wallet_addresses' primary key is sp_address ALONE, so the same SP + address can exist only once table-wide. An SP address is deterministic from + the seed, so after a delete+reimport the same labeled address re-derives while + a stale row (old wallet_id) may still exist. We therefore check by sp_address + globally and, if a row exists under a different wallet_id, re-point it to this + wallet rather than attempting a duplicate insert (which would violate the PK). + """ + if label_index is None or label_index <= BIP352_CHANGE_LABEL_INDEX: + return False + existing = await db.fetchone( + "SELECT wallet_id, label_index FROM silnt.wallet_addresses WHERE sp_address = :addr", + {"addr": sp_address}, + ) + if existing is not None: + # Heal a stale row from a previous incarnation: re-point it to the + # current wallet (and set the label index if it was missing). Preserves + # any user-set label already on the row. + if existing["wallet_id"] != wallet_id or existing["label_index"] != label_index: + await db.execute( + """UPDATE silnt.wallet_addresses + SET wallet_id = :wid, label_index = :idx + WHERE sp_address = :addr""", + {"wid": wallet_id, "idx": label_index, "addr": sp_address}, + ) + return False + # No row anywhere for this address, and the index isn't already used here. + if await label_index_taken(wallet_id, label_index): + return False + await insert_wallet_address(wallet_id, sp_address, label_index, urlsafe_short_hash()) + return True + +async def save_wallet_address( + wallet_id: str, + sp_address: str, + label: Optional[str], + label_index: int, +) -> dict: + addr_id = secrets.token_urlsafe(16) + now = int(time.time()) + await db.execute( + """INSERT INTO silnt.wallet_addresses + (id, wallet_id, sp_address, label, label_index, created_at) + VALUES (:id, :wid, :addr, :label, :idx, :ts)""", + { + "id": addr_id, + "wid": wallet_id, + "addr": sp_address, + "label": (label or "").strip() or None, + "idx": label_index, + "ts": now, + }, + ) + return { + "id": addr_id, + "wallet_id": wallet_id, + "sp_address": sp_address, + "label": label, + "label_index": label_index, + "created_at": now, + } + +async def get_unspent_dust_check(wallet_id: str) -> list[dict]: + rows = await db.fetchall( + """SELECT txid, vout, amount, suspected_dust FROM silnt.utxos + WHERE wallet_id = :wid AND utxo_state = 'unspent'""", + {"wid": wallet_id} + ) + return [dict(r) for r in rows] + +async def update_utxo_dust_flag(txid: str, vout: int, flag: bool) -> None: + await db.execute( + """UPDATE silnt.utxos SET suspected_dust = :flag + WHERE txid = :txid AND vout = :vout""", + {"flag": flag, "txid": txid, "vout": vout}, + ) + + +async def update_utxo_frozen(txid: str, vout: int, frozen: bool) -> None: + await db.execute( + """UPDATE silnt.utxos SET frozen = :frozen + WHERE txid = :txid AND vout = :vout""", + {"frozen": frozen, "txid": txid, "vout": vout}, + ) + +async def owner_check_dust(txid: str, vout: int): + return await db.fetchone( + "SELECT wallet_id FROM silnt.utxos WHERE txid = :txid AND vout = :vout", + {"txid": txid, "vout": vout}, + ) + +async def get_eligible_utxos( + wallet_id: str, + txid_vout_pairs: list[tuple[str, int]], +) -> list[dict]: + """ + Return rows from silnt.utxos that are: + - belong to wallet_id + - state = 'unspent' + - NOT frozen + - match one of the (txid, vout) pairs supplied + """ + if not txid_vout_pairs: + return [] + + # Build a parameterized IN-clause using row tuples + placeholders = [] + params = {"wid": wallet_id} + for i, (txid, vout) in enumerate(txid_vout_pairs): + placeholders.append(f"(:t{i}, :v{i})") + params[f"t{i}"] = txid + params[f"v{i}"] = vout + + sql = f""" + SELECT txid, vout, amount FROM silnt.utxos + WHERE wallet_id = :wid + AND utxo_state = 'unspent' + AND COALESCE(frozen, FALSE) = FALSE + AND (txid, vout) IN ({", ".join(placeholders)}) + """ + rows = await db.fetchall(sql, params) + return [dict(r) for r in rows] + +async def update_address_label(addr_id: str, label: Optional[str]) -> int: + """ + Update only the label string on a labeled address. Returns rows affected. + Empty string is stored as NULL. + """ + label_value = (label or "").strip() or None + result = await db.execute( + "UPDATE silnt.wallet_addresses SET label = :label WHERE id = :id", + {"label": label_value, "id": addr_id}, + ) + return getattr(result, "rowcount", 0) or 0 + +async def get_wallet_unspent_utxos_for_dust_check(wallet_id: str) -> list[dict]: + rows = await db.fetchall( + """SELECT txid, vout, amount, suspected_dust, label_index + FROM silnt.utxos + WHERE wallet_id = :wid AND utxo_state = 'unspent'""", + {"wid": wallet_id}, + ) + return [dict(r) for r in rows] + + +async def get_wallet_owned_txids(wallet_id: str) -> set[str]: + rows = await db.fetchall( + "SELECT txid FROM silnt.utxos WHERE wallet_id = :wid", + {"wid": wallet_id}, + ) + return {r["txid"] for r in rows} + + +async def mark_utxos_spent_by_tx( + wallet_id: str, + input_outpoints: list[tuple[str, int]], + spending_txid: str, +) -> int: + """Mark UTXOs as spent by a tx we broadcast — also records spent_at timestamp.""" + if not input_outpoints: + return 0 + now = int(time.time()) + + placeholders = [] + params = { + "wid": wallet_id, + "spending_txid": spending_txid, + "spent_at": now, + } + for i, (in_txid, in_vout) in enumerate(input_outpoints): + placeholders.append(f"(:t{i}, :v{i})") + params[f"t{i}"] = in_txid + params[f"v{i}"] = in_vout + + sql = f""" + UPDATE silnt.utxos + SET utxo_state = 'unconfirmed_spent', + spent_in_txid = :spending_txid, + spent_at = :spent_at + WHERE wallet_id = :wid + AND (txid, vout) IN ({", ".join(placeholders)}) + """ + result = await db.execute(sql, params) + return getattr(result, "rowcount", 0) or 0 + +async def mark_utxos_spent_by_outpoints( + wallet_id: str, + outpoints: list[tuple[str, int]], # [(txid, vout), ...] + spending_txid: str, +) -> int: + if not outpoints: + return 0 + now = int(time.time()) + affected = 0 + for (in_txid, in_vout) in outpoints: + result = await db.execute( + """UPDATE silnt.utxos + SET utxo_state = 'unconfirmed_spent', + spent_in_txid = :stxid, + spent_at = :ts + WHERE wallet_id = :wid + AND txid = :txid + AND vout = :vout + AND utxo_state IN ('unspent', 'unconfirmed')""", + {"wid": wallet_id, "txid": in_txid, "vout": int(in_vout), + "stxid": spending_txid, "ts": now}, + ) + affected += getattr(result, "rowcount", 0) or 0 + return affected + +async def is_own_sent_tx(wallet_id: str, txid: str) -> bool: + """ + Check if this wallet has broadcast a transaction with this txid. + A row exists with spent_in_txid = txid iff we broadcast it. + """ + row = await db.fetchone( + """SELECT 1 FROM silnt.utxos + WHERE wallet_id = :wid AND spent_in_txid = :txid + LIMIT 1""", + {"wid": wallet_id, "txid": txid}, + ) + return row is not None + +async def get_wallet_owned_outpoints(wallet_id: str) -> set[tuple[str, int]]: + """ + All (txid, vout) outpoints this wallet has ever owned, regardless of state. + Used to classify funding-tx inputs as self-send vs external. + """ + rows = await db.fetchall( + "SELECT txid, vout FROM silnt.utxos WHERE wallet_id = :wid", + {"wid": wallet_id}, + ) + return {(r["txid"], int(r["vout"])) for r in rows} + +async def get_utxo_freeze_reason(txid: str, vout: int) -> Optional[str]: + """Return current freeze_reason ('auto'|'manual'|None) for a UTXO.""" + row = await db.fetchone( + "SELECT freeze_reason FROM silnt.utxos WHERE txid = :txid AND vout = :vout", + {"txid": txid, "vout": vout}, + ) + return row["freeze_reason"] if row else None + +async def set_utxo_freeze_auto(txid: str, vout: int) -> None: + """ + Mark a UTXO as auto-frozen (dust eval owns this lock). Only acts on UTXOs that + are not user-owned: freeze_reason NULL or already 'auto'. A 'manual' freeze or + a 'manual_unfrozen' override is left untouched. + """ + await db.execute( + """UPDATE silnt.utxos + SET frozen = TRUE, freeze_reason = 'auto' + WHERE txid = :txid AND vout = :vout + AND (freeze_reason IS NULL OR freeze_reason = 'auto') + """, + {"txid": txid, "vout": vout}, + ) + +async def set_utxo_freeze_manual(txid: str, vout: int) -> None: + """Mark a UTXO as manually frozen (user owns this lock).""" + await db.execute( + """UPDATE silnt.utxos + SET frozen = TRUE, freeze_reason = 'manual' + WHERE txid = :txid AND vout = :vout""", + {"txid": txid, "vout": vout}, + ) + +async def clear_utxo_freeze_manual(txid: str, vout: int) -> None: + """ + User-initiated unfreeze. Clears the frozen flag regardless of who set it, and + records freeze_reason = 'manual_unfrozen' so the dust evaluator knows this was + a deliberate user override and must NOT auto-re-freeze it. + """ + await db.execute( + """UPDATE silnt.utxos + SET frozen = FALSE, freeze_reason = 'manual_unfrozen' + WHERE txid = :txid AND vout = :vout""", + {"txid": txid, "vout": vout}, + ) + + +async def clear_utxo_freeze_auto(txid: str, vout: int) -> None: + """ + Clear an auto-freeze. NO-OP if the UTXO was manually frozen — only the + user (via the unfreeze endpoint) can clear those. + """ + await db.execute( + """UPDATE silnt.utxos + SET frozen = FALSE, freeze_reason = NULL + WHERE txid = :txid AND vout = :vout AND freeze_reason = 'auto'""", + {"txid": txid, "vout": vout}, + ) + + +async def clear_utxo_freeze_manual(txid: str, vout: int) -> None: + """User-initiated unfreeze — clears regardless of who set it.""" + await db.execute( + """UPDATE silnt.utxos + SET frozen = FALSE, freeze_reason = NULL + WHERE txid = :txid AND vout = :vout""", + {"txid": txid, "vout": vout}, + ) + +async def normalize_unfrozen_override(txid: str, vout: int) -> None: + """Once a UTXO is no longer dust, drop a lingering 'manual_unfrozen' marker.""" + await db.execute( + """UPDATE silnt.utxos SET freeze_reason = NULL + WHERE txid = :txid AND vout = :vout AND freeze_reason = 'manual_unfrozen'""", + {"txid": txid, "vout": vout}, + ) + +async def get_wallet_receives(wallet_id: str) -> list[dict]: + """ + Group all owned UTXOs by funding txid. Each row = one tx where we received. + Returns: [{txid, timestamp, output_sum, output_count, labels[]}, ...] + """ + rows = await db.fetchall( + """ + SELECT + txid, + MIN(timestamp) AS timestamp, + SUM(amount) AS output_sum, + COUNT(*) AS output_count, + ARRAY_REMOVE(ARRAY_AGG(DISTINCT label), NULL) AS labels + FROM silnt.utxos + WHERE wallet_id = :wid + GROUP BY txid + """, + {"wid": wallet_id}, + ) + return [ + { + "txid": r["txid"], + "timestamp": int(r["timestamp"] or 0), + "output_sum": int(r["output_sum"] or 0), + "output_count": int(r["output_count"] or 0), + "labels": list(r["labels"] or []), + } + for r in rows + ] + + +async def get_wallet_sends(wallet_id: str) -> list[dict]: + """ + Group all UTXOs we've spent by spent_in_txid. Each row = one tx we sent. + Returns: [{txid, spent_at, input_sum, input_count}, ...] + """ + rows = await db.fetchall( + """ + SELECT + spent_in_txid AS txid, + MIN(spent_at) AS spent_at, + SUM(amount) AS input_sum, + COUNT(*) AS input_count + FROM silnt.utxos + WHERE wallet_id = :wid AND spent_in_txid IS NOT NULL + GROUP BY spent_in_txid + """, + {"wid": wallet_id}, + ) + return [ + { + "txid": r["txid"], + "spent_at": int(r["spent_at"] or 0), + "input_sum": int(r["input_sum"] or 0), + "input_count": int(r["input_count"] or 0), + } + for r in rows + ] + + +async def get_utxos_for_txid(wallet_id: str, txid: str) -> list[dict]: + """All UTXOs we own at a given funding txid (for the detail view).""" + rows = await db.fetchall( + """SELECT txid, vout, amount, label, label_index, utxo_state, timestamp + FROM silnt.utxos + WHERE wallet_id = :wid AND txid = :txid + ORDER BY vout""", + {"wid": wallet_id, "txid": txid}, + ) + return [dict(r) for r in rows] + + +async def get_utxos_spent_in_tx(wallet_id: str, txid: str) -> list[dict]: + """All UTXOs we spent in a given tx (for the detail view's input list).""" + rows = await db.fetchall( + """SELECT txid, vout, amount, label, label_index, utxo_state, timestamp, spent_at + FROM silnt.utxos + WHERE wallet_id = :wid AND spent_in_txid = :txid + ORDER BY timestamp""", + {"wid": wallet_id, "txid": txid}, + ) + return [dict(r) for r in rows] + + +async def get_owned_pubkeys(wallet_id: str) -> set[str]: + """All x-only output pubkeys this wallet has ever owned — used to filter + out own change from a send tx's outputs (anything not in this set is + treated as a recipient).""" + rows = await db.fetchall( + "SELECT pub_key FROM silnt.utxos WHERE wallet_id = :wid", + {"wid": wallet_id}, + ) + return {r["pub_key"] for r in rows if r["pub_key"]} + +async def add_trusted_device( + user_id: str, + device_id: str, + user_agent: Optional[str], + ip: Optional[str], + label: Optional[str] = None, +) -> dict: + now = int(time.time()) + row_id = secrets.token_urlsafe(16) + await db.execute( + """INSERT INTO silnt.trusted_devices + (id, user_id, device_id, user_agent, ip, label, confirmed_at, last_seen_at) + VALUES (:id, :uid, :did, :ua, :ip, :label, :ts, :ts) + ON CONFLICT (user_id, device_id) DO UPDATE + SET last_seen_at = EXCLUDED.last_seen_at, + user_agent = EXCLUDED.user_agent, + ip = EXCLUDED.ip""", + { + "id": row_id, + "uid": user_id, + "did": device_id, + "ua": (user_agent or "")[:512], + "ip": (ip or "")[:64], + "label": label, + "ts": now, + }, + ) + return {"id": row_id, "device_id": device_id, "confirmed_at": now} + + +async def get_trusted_device(user_id: str, device_id: str) -> Optional[TrustedDevice]: + row = await db.fetchone( + """SELECT * FROM silnt.trusted_devices + WHERE user_id = :uid AND device_id = :did""", + {"uid": user_id, "did": device_id}, + ) + return TrustedDevice(**dict(row)) if row else None + + +async def list_trusted_devices(user_id: str) -> List[TrustedDevice]: + rows = await db.fetchall( + """SELECT * FROM silnt.trusted_devices + WHERE user_id = :uid + ORDER BY confirmed_at DESC""", + {"uid": user_id}, + ) + return [TrustedDevice(**dict(r)) for r in rows] + + +async def count_trusted_devices(user_id: str) -> int: + row = await db.fetchone( + "SELECT COUNT(*) AS c FROM silnt.trusted_devices WHERE user_id = :uid", + {"uid": user_id}, + ) + return int(row["c"] or 0) + + +async def revoke_trusted_device(user_id: str, device_row_id: str) -> int: + result = await db.execute( + """DELETE FROM silnt.trusted_devices + WHERE user_id = :uid AND id = :id""", + {"uid": user_id, "id": device_row_id}, + ) + return getattr(result, "rowcount", 0) or 0 + + +async def revoke_all_other_devices(user_id: str, keep_device_id: str) -> int: + result = await db.execute( + """DELETE FROM silnt.trusted_devices + WHERE user_id = :uid AND device_id != :keep""", + {"uid": user_id, "keep": keep_device_id}, + ) + return getattr(result, "rowcount", 0) or 0 + + +async def touch_trusted_device(user_id: str, device_id: str) -> None: + """Update last_seen_at when the device makes a request.""" + await db.execute( + """UPDATE silnt.trusted_devices + SET last_seen_at = :ts + WHERE user_id = :uid AND device_id = :did""", + {"uid": user_id, "did": device_id, "ts": int(time.time())}, + ) + +async def get_user_prefs(user_id: str) -> Optional[UserPrefs]: + row = await db.fetchone( + "SELECT * FROM silnt.user_prefs WHERE user_id = :uid", + {"uid": user_id}, + ) + return UserPrefs(**dict(row)) if row else None + + +async def upsert_user_prefs( + user_id: str, + dust_threshold_sats: Optional[int], +) -> UserPrefs: + now = int(time.time()) + await db.execute( + """INSERT INTO silnt.user_prefs (user_id, dust_threshold_sats, updated_at) + VALUES (:uid, :dts, :ts) + ON CONFLICT (user_id) DO UPDATE + SET dust_threshold_sats = EXCLUDED.dust_threshold_sats, + updated_at = EXCLUDED.updated_at""", + {"uid": user_id, "dts": dust_threshold_sats, "ts": now}, + ) + return UserPrefs( + user_id = user_id, + dust_threshold_sats = dust_threshold_sats, + updated_at = now, + ) + + + +async def get_effective_dust_threshold( + user_id: str, network: str = DEFAULT_CONFIG_NETWORK +) -> int: + """ + Resolve the dust threshold for a user. + Priority: + 1. User's own prefs.dust_threshold_sats (if non-NULL and > 0) + 2. Admin's BackendConfig.dust_threshold_sats for `network` (if non-zero) + 3. Hard fallback: 5000 sats + """ + prefs = await get_user_prefs(user_id) + if prefs and prefs.dust_threshold_sats and prefs.dust_threshold_sats > 0: + return int(prefs.dust_threshold_sats) + backend = await get_backend_config(network) + return int(backend.dust_threshold_sats or 5000) + +async def create_bip353_request( + user_id: str, + wallet_id: str, + sp_address: str, + requested_username: str, + message: Optional[str], + address_id: Optional[str] = None, +) -> Bip353Request: + row_id = secrets.token_urlsafe(16) + now = int(time.time()) + await db.execute( + """INSERT INTO silnt.bip353_requests + (id, user_id, wallet_id, address_id, sp_address, requested_username, message, + status, created_at) + VALUES (:id, :uid, :wid, :aid, :sp, :uname, :msg, 'pending', :ts)""", + { + "id": row_id, + "uid": user_id, + "wid": wallet_id, + "aid": address_id, + "sp": sp_address, + "uname": requested_username, + "msg": message, + "ts": now, + }, + ) + return Bip353Request( + id=row_id, user_id=user_id, wallet_id=wallet_id, address_id=address_id, + sp_address=sp_address, requested_username=requested_username, message=message, + status="pending", created_at=now, + ) + +async def address_has_approved_bitmail(wallet_id: str, address_id: Optional[str]) -> bool: + """ + True if this specific SP address (base = NULL address_id, or a labeled + address row) has EVER had an approved BitMail. Enforces 'assign once' per + address — the slot stays burned even after removal. + """ + if address_id is None: + row = await db.fetchone( + """SELECT 1 FROM silnt.bip353_requests + WHERE wallet_id = :wid AND address_id IS NULL AND status = 'approved' + LIMIT 1""", + {"wid": wallet_id}, + ) + else: + row = await db.fetchone( + """SELECT 1 FROM silnt.bip353_requests + WHERE wallet_id = :wid AND address_id = :aid AND status = 'approved' + LIMIT 1""", + {"wid": wallet_id, "aid": address_id}, + ) + return row is not None + +async def update_label_hr_address(address_id: str, hr_address: str) -> None: + await db.execute( + "UPDATE silnt.wallet_addresses SET hr_address = :hra WHERE id = :id", + {"hra": hr_address, "id": address_id}, + ) + +async def clear_label_hr_address(address_id: str) -> None: + await db.execute( + "UPDATE silnt.wallet_addresses SET hr_address = NULL WHERE id = :id", + {"id": address_id}, + ) +# Also check the existing wallets.hr_address column (base addresses) + row = await db.fetchone( + """SELECT 1 FROM silnt.wallets + WHERE LOWER(hr_address) LIKE LOWER(:pat)""", + {"pat": f"{username}@%"}, + ) + if row: + return True + # And labeled-address BitMails (wallet_addresses.hr_address) + row = await db.fetchone( + """SELECT 1 FROM silnt.wallet_addresses + WHERE LOWER(hr_address) LIKE LOWER(:pat)""", + {"pat": f"{username}@%"}, + ) + return row is not None + +async def get_user_hr_addresses(user_id: str) -> list[str]: + rows = await db.fetchall( + 'SELECT hr_address FROM silnt.wallets ' + 'WHERE "user" = :uid AND hr_address IS NOT NULL AND hr_address <> \'\'', + {"uid": user_id}, + ) + out = [r["hr_address"] for r in rows if r["hr_address"]] + # Labeled-address BitMails belonging to this user's wallets, too. + label_rows = await db.fetchall( + '''SELECT wa.hr_address FROM silnt.wallet_addresses wa + JOIN silnt.wallets w ON w.id = wa.wallet_id + WHERE w."user" = :uid AND wa.hr_address IS NOT NULL AND wa.hr_address <> \'\'''', + {"uid": user_id}, + ) + out.extend(r["hr_address"] for r in label_rows if r["hr_address"]) + return out + +async def get_bip353_request(req_id: str) -> Optional[Bip353Request]: + row = await db.fetchone( + "SELECT * FROM silnt.bip353_requests WHERE id = :id", + {"id": req_id}, + ) + return Bip353Request(**dict(row)) if row else None + + +async def list_user_bip353_requests(user_id: str) -> List[Bip353Request]: + rows = await db.fetchall( + """SELECT * FROM silnt.bip353_requests + WHERE user_id = :uid + ORDER BY created_at DESC""", + {"uid": user_id}, + ) + return [Bip353Request(**dict(r)) for r in rows] + + +async def list_pending_bip353_requests() -> List[Bip353Request]: + rows = await db.fetchall( + """SELECT * FROM silnt.bip353_requests + WHERE status = 'pending' + ORDER BY created_at ASC""" + ) + return [Bip353Request(**dict(r)) for r in rows] + + +async def is_username_taken(username: str) -> bool: + """Check if username is already approved + active for someone.""" + row = await db.fetchone( + """SELECT 1 FROM silnt.bip353_requests + WHERE ( + (status = 'approved' AND LOWER(final_username) = LOWER(:uname)) + OR (status = 'pending' AND LOWER(requested_username) = LOWER(:uname)) + )""", + {"uname": username}, + ) + if row: + return True + # Also check the existing wallets.hr_address column (legacy addresses) + row = await db.fetchone( + """SELECT 1 FROM silnt.wallets + WHERE LOWER(hr_address) LIKE LOWER(:pat)""", + {"pat": f"{username}@%"}, + ) + return row is not None + +async def is_username_approved_elsewhere(username: str, exclude_req_id: str) -> bool: + """True if some OTHER approved request already holds this username.""" + row = await db.fetchone( + """SELECT 1 FROM silnt.bip353_requests + WHERE status = 'approved' + AND LOWER(final_username) = LOWER(:uname) + AND id <> :rid""", + {"uname": username, "rid": exclude_req_id}, + ) + return row is not None + +async def sp_address_has_approved_bitmail(sp_address: str) -> bool: + """True if this Silent Payment address has EVER had an approved BitMail — + keyed by the SP address itself, which is stable across delete+re-add of a + labeled address (address_id is not). Enforces the permanent 'assign once' + rule even if the user removes and re-creates the labeled address.""" + if not sp_address: + return False + row = await db.fetchone( + """SELECT 1 FROM silnt.bip353_requests + WHERE sp_address = :sp AND status = 'approved' + LIMIT 1""", + {"sp": sp_address}, + ) + return row is not None + +async def update_bip353_request_status( + req_id: str, + status: str, + processed_by: str, + final_username: Optional[str] = None, + reject_reason: Optional[str] = None, +) -> int: + now = int(time.time()) + result = await db.execute( + """UPDATE silnt.bip353_requests + SET status = :status, + final_username = :final_username, + reject_reason = :reject_reason, + processed_at = :ts, + processed_by = :by + WHERE id = :id""", + { + "id": req_id, + "status": status, + "final_username": final_username, + "reject_reason": reject_reason, + "ts": now, + "by": processed_by, + }, + ) + return getattr(result, "rowcount", 0) or 0 + + +async def cancel_user_request(req_id: str, user_id: str) -> int: + """User cancels their own pending request.""" + result = await db.execute( + """UPDATE silnt.bip353_requests + SET status = 'cancelled', processed_at = :ts + WHERE id = :id AND user_id = :uid AND status = 'pending'""", + {"id": req_id, "uid": user_id, "ts": int(time.time())}, + ) + return getattr(result, "rowcount", 0) or 0 + +async def get_wallet_active_request(wallet_id: str) -> Optional[Bip353Request]: + """Return the currently pending request for THIS wallet, if any.""" + row = await db.fetchone( + """SELECT * FROM silnt.bip353_requests + WHERE wallet_id = :wid AND status = 'pending' + ORDER BY created_at DESC LIMIT 1""", + {"wid": wallet_id}, + ) + return Bip353Request(**dict(row)) if row else None + + +async def get_wallet_last_rejected_request(wallet_id: str) -> Optional[Bip353Request]: + """Last rejection for THIS wallet — used for per-wallet cooldown.""" + row = await db.fetchone( + """SELECT * FROM silnt.bip353_requests + WHERE wallet_id = :wid AND status = 'rejected' + ORDER BY processed_at DESC LIMIT 1""", + {"wid": wallet_id}, + ) + return Bip353Request(**dict(row)) if row else None + +async def get_utxo(wallet_id: str, txid: str, vout: int) -> Optional[dict]: + """ + Fetch a single UTXO by (wallet_id, txid, vout). + Returns a dict with txid, vout, spent_in_txid, utxo_state — or None if absent. + """ + rows = await db.fetchall( + """SELECT txid, vout, spent_in_txid, utxo_state + FROM silnt.utxos + WHERE wallet_id = :wid AND txid = :txid AND vout = :vout""", + {"wid": wallet_id, "txid": txid, "vout": vout}, + ) + return rows[0] if rows else None + + +async def restore_utxo_to_unspent(wallet_id: str, txid: str, vout: int) -> int: + """ + Restore an unconfirmed_spent UTXO back to unspent (clears spend metadata). + Only affects rows currently in 'unconfirmed_spent' state. Returns rowcount. + """ + result = await db.execute( + """UPDATE silnt.utxos + SET utxo_state = 'unspent', spent_in_txid = NULL, spent_at = NULL + WHERE wallet_id = :wid AND txid = :txid AND vout = :vout + AND utxo_state = 'unconfirmed_spent'""", + {"wid": wallet_id, "txid": txid, "vout": vout}, + ) + return getattr(result, "rowcount", 0) or 0 + + +async def get_wallet_unspent_balance(wallet_id: str) -> int: + """Sum of all 'unspent' UTXO amounts for a wallet.""" + rows = await db.fetchall( + "SELECT amount FROM silnt.utxos WHERE wallet_id = :wid AND utxo_state = 'unspent'", + {"wid": wallet_id}, + ) + return sum(r["amount"] for r in rows) + +async def delete_all_silnt_data_for_user(user_id: str) -> dict: + """ + Remove every siLNt artifact belonging to a user across ALL networks: UTXOs, + addresses, and wallet rows. Network-agnostic — selects the user's wallets + directly so it doesn't depend on enumerating networks. Returns counts. + """ + wallet_rows = await db.fetchall( + 'SELECT id FROM silnt.wallets WHERE "user" = :uid', + {"uid": user_id}, + ) + wallet_ids = [r["id"] for r in wallet_rows] + if not wallet_ids: + return {"wallets_deleted": 0} + + for wid in wallet_ids: + await db.execute("DELETE FROM silnt.utxos WHERE wallet_id = :wid", {"wid": wid}) + await db.execute("DELETE FROM silnt.wallet_addresses WHERE wallet_id = :wid", {"wid": wid}) + await db.execute("DELETE FROM silnt.wallets WHERE id = :wid", {"wid": wid}) + + # Per-user data (keyed by user_id, not wallet_id) — these must be cleaned even + # if the user somehow had no wallets, so do them unconditionally. + await db.execute("DELETE FROM silnt.bip353_requests WHERE user_id = :uid", {"uid": user_id}) + await db.execute("DELETE FROM silnt.trusted_devices WHERE user_id = :uid", {"uid": user_id}) + await db.execute("DELETE FROM silnt.user_prefs WHERE user_id = :uid", {"uid": user_id}) + # Admin alerts reference the user only inside their meta JSON (no column), so + # clean them via the meta-aware helper rather than a DELETE ... WHERE. + await delete_admin_alerts_for_user(user_id) + # Boltz swaps for the user's wallets. + for wid in wallet_ids: + await db.execute("DELETE FROM silnt.boltz_swaps WHERE silnt_wallet_id = :wid", {"wid": wid}) + # PayJoin: requests where the user is either side, their imported descriptors, + # connection rows where they're requester or target, and their private labels. + await db.execute( + "DELETE FROM silnt.payjoin_requests WHERE sender_user_id = :uid OR receiver_user_id = :uid", + {"uid": user_id}, + ) + await db.execute("DELETE FROM silnt.payjoin_descriptors WHERE user_id = :uid", {"uid": user_id}) + await db.execute( + "DELETE FROM silnt.payjoin_contacts WHERE requester_user_id = :uid OR target_user_id = :uid", + {"uid": user_id}, + ) + await db.execute("DELETE FROM silnt.payjoin_contact_labels WHERE labeler_user_id = :uid", {"uid": user_id}) + # SP send contacts (private address book). + await db.execute("DELETE FROM silnt.sp_contacts WHERE user_id = :uid", {"uid": user_id}) + + return {"wallets_deleted": len(wallet_ids), "wallet_ids": wallet_ids} + + +async def list_silnt_user_ids_for_network(network: str) -> list[str]: + """User_ids that have siLNt presence ON a specific network — i.e. a wallet or + a PayJoin descriptor whose network matches. Used by the network-locked admin + Accounts page so the mainnet portal doesn't list signet-only users. + (trusted_devices and bip353_requests are network-agnostic, so they are not + used to scope network membership.)""" + ids: set[str] = set() + for sql, col in ( + ('SELECT DISTINCT "user" FROM silnt.wallets WHERE "user" IS NOT NULL AND network = :net', "user"), + ("SELECT DISTINCT user_id FROM silnt.payjoin_descriptors WHERE network = :net", "user_id"), + ): + try: + rows = await db.fetchall(sql, {"net": network}) + except Exception: + continue + for r in rows: + v = r[col] + if v: + ids.add(v) + return sorted(ids) + +async def clear_wallet_hr_address(wallet_id: str) -> None: + """Blank a wallet's hr_address after its BitMail DNS record is removed.""" + await db.execute( + "UPDATE silnt.wallets SET hr_address = '' WHERE id = :wid", + {"wid": wallet_id}, + ) + +async def count_approved_bip353_for_wallet(wallet_id: str) -> int: + """How many times this wallet has been granted a BitMail address (approved).""" + row = await db.fetchone( + "SELECT COUNT(*) AS c FROM silnt.bip353_requests " + "WHERE wallet_id = :wid AND status = 'approved'", + {"wid": wallet_id}, + ) + # db.fetchone shape may be a mapping or a Row; handle both. + if row is None: + return 0 + try: + return int(row["c"]) + except (KeyError, TypeError): + return int(row[0]) + +async def create_boltz_swap(rec: BoltzSwapRecord) -> BoltzSwapRecord: + await db.execute( + """ + INSERT INTO silnt.boltz_swaps + (id, wallet_id, silnt_wallet_id, status, timeout_block_height, json_data) + VALUES (:id, :wallet_id, :silnt_wallet_id, :status, :timeout, :json_data) + """, + { + "id": rec.id, + "wallet_id": rec.wallet_id, + "silnt_wallet_id": rec.silnt_wallet_id, + "status": rec.status, + "timeout": rec.timeout_block_height, + "json_data": rec.json(), + }, + ) + return rec + +async def get_boltz_swap(swap_id: str) -> Optional[BoltzSwapRecord]: + row = await db.fetchone( + "SELECT json_data FROM silnt.boltz_swaps WHERE id = :id", {"id": swap_id} + ) + return BoltzSwapRecord(**json.loads(row["json_data"])) if row else None + +async def update_boltz_swap(rec: BoltzSwapRecord) -> BoltzSwapRecord: + await db.execute( + """ + UPDATE silnt.boltz_swaps + SET status = :status, timeout_block_height = :timeout, + updated_at = now(), json_data = :json_data + WHERE id = :id + """, + {"status": rec.status, "timeout": rec.timeout_block_height, + "json_data": rec.json(), "id": rec.id}, + ) + return rec + +async def list_boltz_swaps_by_status(status: str) -> list[BoltzSwapRecord]: + rows = await db.fetchall( + "SELECT json_data FROM silnt.boltz_swaps WHERE status = :status", + {"status": status}, + ) + return [BoltzSwapRecord(**json.loads(r["json_data"])) for r in rows] + +async def list_boltz_swaps_for_wallet( + wallet_id: str, silnt_wallet_id: str | None = None +) -> list[BoltzSwapRecord]: + rows = await db.fetchall( + """ + SELECT json_data FROM silnt.boltz_swaps + WHERE wallet_id = :w OR silnt_wallet_id = :s + ORDER BY created_at DESC + """, + {"w": wallet_id, "s": silnt_wallet_id or wallet_id}, + ) + return [BoltzSwapRecord(**json.loads(r["json_data"])) for r in rows] + + +async def delete_boltz_swap(swap_id: str) -> None: + await db.execute( + "DELETE FROM silnt.boltz_swaps WHERE id = :id", + {"id": swap_id}, + ) + +async def mark_utxos_confirmed_spent_by_tx(wallet_id: str, spending_txid: str) -> int: + """ + Finalize a confirmed send: move this wallet's inputs spent in `spending_txid` + from 'unconfirmed_spent' to 'spent'. Returns rows affected. Idempotent — + re-running on an already-'spent' tx changes nothing. + """ + result = await db.execute( + """UPDATE silnt.utxos + SET utxo_state = 'spent' + WHERE wallet_id = :wid + AND spent_in_txid = :txid + AND utxo_state = 'unconfirmed_spent'""", + {"wid": wallet_id, "txid": spending_txid}, + ) + return getattr(result, "rowcount", 0) or 0 + +async def cancel_pending_request_for_address( + wallet_id: str, address_id: Optional[str], sp_address: Optional[str] = None +) -> int: + """Cancel any PENDING BitMail request bound to a specific address (a label, + or the wallet base when address_id is None). Used when the address/wallet is + deleted so the request doesn't linger in the admin queue. Approved rows are + left intact (they preserve the wallet's lifetime cap and keep the username + reserved). + + Matches on address_id AND, when provided, the address's sp_address. The + sp_address fallback is important because a labeled-address row can be + re-created by a scan with a NEW row id (BIP-352 addresses are deterministic + from the seed, but the wallet_addresses.id is random), which would otherwise + orphan a request whose stored address_id points at the old row id.""" + ts = int(time.time()) + if address_id is None and not sp_address: + result = await db.execute( + """UPDATE silnt.bip353_requests + SET status = 'cancelled', processed_at = :ts + WHERE wallet_id = :wid AND address_id IS NULL AND status = 'pending'""", + {"wid": wallet_id, "ts": ts}, + ) + else: + # Build the OR-match in Python so each bind param only appears in a typed + # comparison (col = :param). Using ':param IS NOT NULL' in SQL leaves the + # param's type indeterminate for asyncpg → IndeterminateDatatypeError. + clauses = [] + params = {"wid": wallet_id, "ts": ts} + if address_id is not None: + clauses.append("address_id = :aid") + params["aid"] = address_id + if sp_address: + clauses.append("sp_address = :sp") + params["sp"] = sp_address + where_match = " OR ".join(clauses) + result = await db.execute( + f"""UPDATE silnt.bip353_requests + SET status = 'cancelled', processed_at = :ts + WHERE wallet_id = :wid AND status = 'pending' + AND ({where_match})""", + params, + ) + return getattr(result, "rowcount", 0) or 0 + + +async def cancel_all_pending_requests_for_wallet(wallet_id: str) -> int: + """Cancel ALL pending BitMail requests for a wallet (any address). Used when + the whole wallet is deleted.""" + result = await db.execute( + """UPDATE silnt.bip353_requests + SET status = 'cancelled', processed_at = :ts + WHERE wallet_id = :wid AND status = 'pending'""", + {"wid": wallet_id, "ts": int(time.time())}, + ) + return getattr(result, "rowcount", 0) or 0 + +async def delete_bip353_requests_for_wallet(wallet_id: str) -> int: + """Delete ALL BitMail requests (any status) tied to a wallet. Called when the + wallet itself is deleted, so approved BitMails don't linger in + list_approved_bitmails() (which drives the tamper sweep) after their owning + wallet is gone. The wallet's live DNS records are removed separately by the + delete endpoint before this runs.""" + if not wallet_id: + return 0 + result = await db.execute( + "DELETE FROM silnt.bip353_requests WHERE wallet_id = :wid", + {"wid": wallet_id}, + ) + return getattr(result, "rowcount", 0) or 0 + +async def list_all_bip353_requests(limit: int = 13, offset: int = 0) -> list[Bip353Request]: + """All BitMail requests (any status), newest first, paginated. Admin history.""" + rows = await db.fetchall( + """SELECT * FROM silnt.bip353_requests + ORDER BY created_at DESC + LIMIT :limit OFFSET :offset""", + {"limit": limit, "offset": offset}, + ) + return [Bip353Request(**r) for r in rows] + +async def delete_bip353_request_if_terminal(req_id: str) -> int: + """Delete a single request ONLY if it is rejected or cancelled. Approved and + pending rows are protected (approved = burned-slot/username record; pending = + still in the queue). Returns rows deleted (0 if not terminal / not found).""" + result = await db.execute( + """DELETE FROM silnt.bip353_requests + WHERE id = :id AND status IN ('rejected', 'cancelled')""", + {"id": req_id}, + ) + return getattr(result, "rowcount", 0) or 0 + + +async def delete_terminal_bip353_requests() -> int: + """Bulk-delete ALL rejected/cancelled requests. Approved/pending untouched.""" + result = await db.execute( + """DELETE FROM silnt.bip353_requests + WHERE status IN ('rejected', 'cancelled')""" + ) + return getattr(result, "rowcount", 0) or 0 + +# ── descriptor parsing ──────────────────────────────────────────────────────── +def _embit_net(network: str): + n = network.lower() + if n == "mainnet": + return NETWORKS["main"] + if n == "regtest": + return NETWORKS["regtest"] + return NETWORKS["test"] + + +def _fmt_path(derivation: list[int]) -> str: + """[84',1',0'] ints -> '84h/1h/0h'.""" + H = 0x80000000 + parts = [] + for i in derivation: + if i >= H: + parts.append(f"{i - H}h") + else: + parts.append(str(i)) + return "/".join(parts) + + +def _strip_checksum(descriptor: str) -> str: + """Remove BIP-380 '#checksum' so a Sparrow export pasted verbatim parses. + Checksum is a trailing '#'+8 chars; xpubs/paths never contain '#'.""" + s = (descriptor or "").strip() + if "#" in s: + s = s[: s.rindex("#")].strip() + return s + +def _normalize_multipath(descriptor: str) -> str: + """ + Repair the key-path branch spec so embit can parse it and so BOTH the receive + (0) and change (1) chains are covered. + + Some clients mangle the literal '<0;1>' (it contains '<' '>') into an empty + branch, e.g. '...xpub//*'. Others export a single chain '.../0/*'. Normalize + all of these to the canonical multipath '.../<0;1>/*'. + + Operates on the checksum-stripped string. Only touches the key-path tail + (after the xpub); never alters the xpub or the [origin] prefix. + """ + s = _strip_checksum(descriptor) + # The tail we care about is the '/.../*' after the xpub, before the closing ')'. + # Repair the two known bad/!canonical forms: + # //* (empty branch -> the <0;1> was eaten) + # /<0;1>/* (already canonical -> leave) + # /0/* (single receive chain -> expand to multipath) + if "//*" in s: + s = s.replace("//*", "/<0;1>/*") + elif "/<0;1>/*" in s: + pass # canonical + elif "/0/*" in s and "/<0;1>/*" not in s: + s = s.replace("/0/*", "/<0;1>/*") + return s + +def _prepare_descriptor(descriptor: str) -> str: + """Checksum-stripped + multipath-normalized string for embit parsing.""" + return _normalize_multipath(descriptor) + +# ── At-rest encryption for PayJoin descriptors/xpubs ────────────────────────── +# An account xpub is privacy-sensitive: anyone holding it can derive all of a +# wallet's addresses and reconstruct its full balance/history (watch-only, not +# spend). The PayJoin flow needs the descriptor server-side to coordinate, so we +# encrypt it AT REST. This protects a leaked DB dump/backup; it does NOT protect +# against a full host compromise (the attacker would also obtain the key). The +# key is the per-instance LNbits auth_secret. +def _payjoin_enc_key() -> str: + """Resolve a stable per-instance secret to key at-rest encryption. + + LNbits has renamed this across versions, so probe the known attribute names, + then environment variables, then derive a stable fallback from instance-level + values. Encryption protects a leaked DB dump/backup, not a full host + compromise (which would also expose the key).""" + import os + try: + from lnbits.settings import settings as _s + except Exception: + _s = None + if _s is not None: + for attr in ( + "auth_secret_key", # confirmed name on this instance (see device_auth.py) + "auth_secret", "secret", "secret_key", + "lnbits_secret", + ): + v = getattr(_s, attr, None) + if v: + return str(v) + for env in ("LNBITS_AUTH_SECRET", "AUTH_SECRET", "LNBITS_SECRET_KEY", "SECRET_KEY"): + v = os.environ.get(env) + if v: + return v + # Last-resort stable fallback: derive from instance-level values that persist + # across restarts (data dir + superuser id). Not as strong as a dedicated + # secret, but deterministic so encrypted rows remain decryptable. + import hashlib + seed = "" + if _s is not None: + seed = (str(getattr(_s, "lnbits_data_folder", "") or "") + + "|" + str(getattr(_s, "super_user", "") or "")) + seed = seed or os.environ.get("LNBITS_DATA_FOLDER", "") or "silnt-static-fallback" + return hashlib.sha256(("silnt-pj-enc|" + seed).encode()).hexdigest() + +def _pj_encrypt(plaintext: str) -> str: + from lnbits.utils.crypto import AESCipher + return AESCipher(key=_payjoin_enc_key()).encrypt(plaintext.encode()) + +def _pj_decrypt(ciphertext: str) -> str: + from lnbits.utils.crypto import AESCipher + return AESCipher(key=_payjoin_enc_key()).decrypt(ciphertext) + +def _xpub_fingerprint_hash(xpub: str) -> str: + """Deterministic, non-reversible tag for dedup/equality lookups without + storing the xpub in plaintext. SHA256 of an xpub can't be turned back into + the xpub (so no address derivation), but equal xpubs map to equal tags.""" + import hashlib + return hashlib.sha256(xpub.strip().encode()).hexdigest() + +def _looks_encrypted(val: str) -> bool: + """Heuristic: a stored value is ciphertext (base64 from AESCipher) rather than + a plaintext descriptor/xpub. Real descriptors contain '(' and key-prefixes; + xpubs start with x/t/y/z-pub. Base64 ciphertext does not.""" + if not val: + return False + v = val.strip() + # Plaintext descriptor markers + if "(" in v or v[:4] in ("wpkh", "pkh(", "tr(", "sh(", "combo", "addr", "raw("): + return False + # Plaintext xpub markers + if v[:4].lower() in ("xpub", "tpub", "ypub", "zpub", "vpub", "upub"): + return False + return True + +def _decrypt_descriptor_row(row: dict) -> dict: + """Return a row dict with descriptor/xpub decrypted for model construction. + Tolerates legacy plaintext rows (pre-encryption). If a value LOOKS encrypted + but cannot be decrypted (e.g. encrypted under a now-unreachable key), raise — + never hand raw base64 ciphertext downstream, which corrupts the descriptor + parser with an 'invalid character' error.""" + out = dict(row) + for field in ("descriptor", "xpub"): + val = out.get(field) + if not val: + continue + try: + out[field] = _pj_decrypt(val) + except Exception: + if _looks_encrypted(val): + raise RuntimeError( + f"PayJoin {field} is encrypted but could not be decrypted with the " + f"current key. The instance secret (auth_secret_key) likely changed " + f"since it was encrypted. Re-import this wallet descriptor." + ) + # Otherwise it's genuine legacy plaintext — leave as-is. + return out + +def parse_descriptor(descriptor: str) -> dict: + """ + Parse + validate a BIP-84 output descriptor (Sparrow export). + Returns {xpub, master_fp, account_path, script_type}. Raises ValueError on + anything that isn't a single-key wpkh descriptor. + """ + try: + d = Descriptor.from_string(_prepare_descriptor(descriptor)) + except Exception as e: + raise ValueError(f"Could not parse descriptor: {e}") + if not d.wpkh: + raise ValueError("Only BIP-84 native SegWit (wpkh) descriptors are supported.") + if len(d.keys) != 1: + raise ValueError("Only single-key descriptors are supported.") + k = d.keys[0] + if k.origin is None: + raise ValueError("Descriptor is missing key origin ([fingerprint/path]).") + return { + "xpub": str(k.key).split("/")[0], # strip any /<0;1>/* suffix + "master_fp": k.origin.fingerprint.hex(), + "account_path": _fmt_path(k.origin.derivation), + "script_type": "wpkh", + } + +def derive_descriptor_address(descriptor: str, network: str, chain: int, index: int) -> str: + """Derive a concrete address from the descriptor: chain 0=receive,1=change.""" + d = Descriptor.from_string(_prepare_descriptor(descriptor)) + return d.derive(index, branch_index=chain).address(_embit_net(network)) + +# ── descriptors CRUD ────────────────────────────────────────────────────────── +async def create_payjoin_descriptor( + user_id: str, descriptor: str, network: str, label: Optional[str] = None +) -> PayjoinDescriptor: + parsed = parse_descriptor(descriptor) + # Dedup on a non-reversible hash of the xpub (the xpub itself is stored + # encrypted, so a plaintext equality match isn't possible). + xhash = _xpub_fingerprint_hash(parsed["xpub"]) + existing = await db.fetchall( + "SELECT id FROM silnt.payjoin_descriptors WHERE user_id = :uid AND xpub_sha256 = :xh", + {"uid": user_id, "xh": xhash}, + ) + if existing: + raise ValueError("This wallet (xpub) is already imported.") + did = urlsafe_short_hash() + # Encrypt the privacy-sensitive fields at rest. master_fp/account_path are + # low-sensitivity (fingerprint + derivation path) and left as-is for display. + enc_descriptor = _pj_encrypt(descriptor.strip()) + enc_xpub = _pj_encrypt(parsed["xpub"]) + await db.execute( + """ + INSERT INTO silnt.payjoin_descriptors + (id, user_id, label, descriptor, xpub, xpub_sha256, master_fp, account_path, + script_type, network) + VALUES + (:id, :user_id, :label, :descriptor, :xpub, :xpub_sha256, :master_fp, :account_path, + :script_type, :network) + """, + { + "id": did, "user_id": user_id, "label": label, + "descriptor": enc_descriptor, "xpub": enc_xpub, "xpub_sha256": xhash, + "master_fp": parsed["master_fp"], "account_path": parsed["account_path"], + "script_type": parsed["script_type"], "network": network, + }, + ) + return await get_payjoin_descriptor(did) + + +async def get_payjoin_descriptor(did: str) -> Optional[PayjoinDescriptor]: + row = await db.fetchone( + "SELECT * FROM silnt.payjoin_descriptors WHERE id = :id", {"id": did} + ) + return PayjoinDescriptor(**_decrypt_descriptor_row(row)) if row else None + + +async def list_payjoin_descriptors(user_id: str) -> list[PayjoinDescriptor]: + rows = await db.fetchall( + "SELECT * FROM silnt.payjoin_descriptors WHERE user_id = :uid ORDER BY created_at DESC", + {"uid": user_id}, + ) + return [PayjoinDescriptor(**_decrypt_descriptor_row(r)) for r in rows] + +async def list_payjoin_descriptor_user_ids(exclude_user_id: Optional[str] = None) -> list[str]: + """Distinct user_ids that have imported at least one PayJoin descriptor + (i.e. can actually receive a PayJoin). Optionally exclude the caller.""" + rows = await db.fetchall( + "SELECT DISTINCT user_id FROM silnt.payjoin_descriptors", {} + ) + ids = [r["user_id"] for r in rows] + if exclude_user_id: + ids = [i for i in ids if i != exclude_user_id] + return ids + +async def delete_payjoin_descriptor(did: str, user_id: str) -> None: + await db.execute( + "DELETE FROM silnt.payjoin_descriptors WHERE id = :id AND user_id = :uid", + {"id": did, "uid": user_id}, + ) + + +async def get_reserved_outpoints(user_id: str) -> set: + """ + Outpoints (txid:vout) reserved by this user's PayJoins, so they can't be + double-selected in another siLNt PayJoin and so the Create/Pay pickers agree + with the wallet balance. + + Includes: + - In-progress PayJoins (OPEN/CLAIMED/PROPOSED/ACCEPTED/CONTRIBUTED/FINALIZING). + - BROADCAST PayJoins whose spend is still UNCONFIRMED. Fulcrum's listunspent + keeps a being-spent UTXO until the spend confirms, while the wallet + balance already subtracts it (unconfirmed). Without reserving these, the + Create tab would still list the in-flight UTXO as available and show the + pre-PayJoin amount — disagreeing with the Wallets balance. Once the spend + confirms, Fulcrum drops the UTXO from listunspent, so it no longer appears + regardless of status; over-reserving a confirmed-spent outpoint is + therefore harmless (it isn't listed anymore). + + (Watch-only: this is siLNt-scope only; it can't stop the user spending the + coins directly in their own wallet.) + """ + rows = await db.fetchall( + """ + SELECT sender_inputs, receiver_input FROM silnt.payjoin_requests + WHERE (sender_user_id = :uid OR receiver_user_id = :uid) + AND status IN ('OPEN','CLAIMED','PROPOSED','ACCEPTED','CONTRIBUTED','FINALIZING','BROADCAST') + """, + {"uid": user_id}, + ) + reserved = set() + for r in rows: + for col in ("sender_inputs", "receiver_input"): + raw = r[col] + if not raw: + continue + try: + data = json.loads(raw) + except Exception: + continue + items = data if isinstance(data, list) else [data] + for u in items: + if isinstance(u, dict) and "txid" in u and "vout" in u: + reserved.add(f"{u['txid']}:{u['vout']}") + return reserved + +# ── requests CRUD ───────────────────────────────────────────────────────────── +async def create_payjoin_request( + sender_user_id: str, sender_username: str, sender_descriptor_id: str, + receiver_username: str, amount_sats: int, fee_rate: float, + payment_address: str, sender_inputs: list[dict], + receiver_user_id: Optional[str] = None, expiry_seconds: int = 3600, +) -> PayjoinRequest: + rid = urlsafe_short_hash() + # expires_at as a real datetime (TIMESTAMP column). Match however your other + # CRUD writes datetimes — if they pass datetime objects, this is correct; if + # they use db.timestamp_now + interval SQL, adapt accordingly. + expires_at = int(time.time()) + expiry_seconds + await db.execute( + """ + INSERT INTO silnt.payjoin_requests + (id, status, sender_user_id, sender_username, sender_descriptor_id, + receiver_user_id, receiver_username, amount_sats, fee_rate, + payment_address, sender_inputs, expires_at) + VALUES + (:id, 'PROPOSED', :suid, :suser, :sdid, :ruid, :ruser, :amount, + :fee_rate, :pay_addr, :sinputs, :expires) + """, + { + "id": rid, "suid": sender_user_id, "suser": sender_username, + "sdid": sender_descriptor_id, "ruid": receiver_user_id, + "ruser": receiver_username, "amount": amount_sats, "fee_rate": fee_rate, + "pay_addr": payment_address, "sinputs": json.dumps(sender_inputs), + "expires": expires_at, + }, + ) + return await get_payjoin_request(rid) + + +async def get_payjoin_request(rid: str) -> Optional[PayjoinRequest]: + row = await db.fetchone( + "SELECT * FROM silnt.payjoin_requests WHERE id = :id", {"id": rid} + ) + return PayjoinRequest(**row) if row else None + + +async def create_payjoin_invoice( + payee_user_id: str, payee_username: str, payee_descriptor_id: str, + payee_input: dict, payment_address: str, + payer_user_id: str, payer_username: str, + amount_sats: int, fee_rate: float, memo: Optional[str] = None, + expiry_seconds: int = 86400, +) -> PayjoinRequest: + """ + A (payee) creates a directed invoice for payer B. Reuses payjoin_requests: + receiver_* = payee A (set now, incl. A's one contributed input); + sender_* = payer B (identity set now; B's inputs filled when B pays). + Status OPEN. Payment address is A's (next-unused) receive address. + """ + rid = urlsafe_short_hash() + expires_at = int(time.time()) + expiry_seconds + await db.execute( + """ + INSERT INTO silnt.payjoin_requests + (id, status, sender_user_id, sender_username, sender_descriptor_id, + receiver_user_id, receiver_username, receiver_descriptor_id, + receiver_input, receiver_input_sats, amount_sats, fee_rate, + payment_address, memo, expires_at) + VALUES + (:id, 'OPEN', :buid, :buser, :bdid, :auid, :auser, :adid, + :ainput, :ain_sats, :amount, :fee_rate, :pay_addr, :memo, :expires) + """, + { + "id": rid, + "buid": payer_user_id, "buser": payer_username, "bdid": "", + "auid": payee_user_id, "auser": payee_username, "adid": payee_descriptor_id, + "ainput": json.dumps(payee_input), "ain_sats": int(payee_input["value"]), + "amount": amount_sats, "fee_rate": fee_rate, + "pay_addr": payment_address, "memo": memo, "expires": expires_at, + }, + ) + return await get_payjoin_request(rid) + + +async def list_payjoin_invoices_for_payer(payer_user_id: str) -> list[PayjoinRequest]: + """OPEN invoices directed to this user (as payer B).""" + rows = await db.fetchall( + "SELECT * FROM silnt.payjoin_requests " + "WHERE sender_user_id = :uid AND status = 'OPEN' ORDER BY created_at DESC", + {"uid": payer_user_id}, + ) + return [PayjoinRequest(**r) for r in rows] + + +# ── connections (consent-based curated list) ────────────────────────────────── +async def get_account_id_by_email(email: str): + """ + Resolve an email -> (user_id, username) using the LNbits core accounts table. + Prefers the core helper if present, else queries the core DB directly. + Returns (user_id, username) or (None, None) if no such account. Email match + is case-insensitive. Never raises on 'not found' (so callers can stay neutral + and avoid an email-existence oracle). + """ + em = (email or "").strip().lower() + if not em or "@" not in em: + return (None, None) + # try core helper first + try: + from lnbits.core.crud import get_account_by_email # may not exist in all versions + acct = await get_account_by_email(em) + if acct: + return (acct.id, getattr(acct, "username", None) or em) + except Exception: + pass + # fallback: direct query against the core accounts table + try: + from lnbits.core.db import db as core_db + row = await core_db.fetchone( + "SELECT id, username FROM accounts WHERE LOWER(email) = :em", + {"em": em}, + ) + if row: + return (row["id"], row["username"] or em) + except Exception: + pass + return (None, None) + + +async def create_payjoin_contact(requester_user_id: str, target_user_id: str) -> "PayjoinContact": + """Create a PENDING connection request (or return the existing row if one + already exists between these two users in either direction). Stores only + user_ids — usernames are resolved on demand for display.""" + existing = await db.fetchone( + """SELECT * FROM silnt.payjoin_contacts + WHERE (requester_user_id = :a AND target_user_id = :b) + OR (requester_user_id = :b AND target_user_id = :a)""", + {"a": requester_user_id, "b": target_user_id}, + ) + if existing: + return PayjoinContact(**existing) + cid = urlsafe_short_hash() + await db.execute( + """INSERT INTO silnt.payjoin_contacts + (id, status, requester_user_id, target_user_id) + VALUES (:id, 'PENDING', :ruid, :tuid)""", + {"id": cid, "ruid": requester_user_id, "tuid": target_user_id}, + ) + return await get_payjoin_contact(cid) + + +async def get_payjoin_contact(cid: str) -> Optional["PayjoinContact"]: + row = await db.fetchone("SELECT * FROM silnt.payjoin_contacts WHERE id = :id", {"id": cid}) + return PayjoinContact(**row) if row else None + + +async def set_payjoin_contact_status(cid: str, status: str) -> None: + await db.execute( + f"UPDATE silnt.payjoin_contacts SET status = :s, updated_at = {db.timestamp_now} WHERE id = :id", + {"s": status, "id": cid}, + ) + + +async def delete_payjoin_contact(cid: str) -> None: + await db.execute("DELETE FROM silnt.payjoin_contacts WHERE id = :id", {"id": cid}) + # also drop any private labels attached to it + await db.execute("DELETE FROM silnt.payjoin_contact_labels WHERE contact_id = :id", {"id": cid}) + + +async def set_payjoin_contact_label(contact_id: str, labeler_user_id: str, label: str) -> None: + """Set/clear a private per-side label for a connection (only the labeler sees + it). Blank label clears it.""" + lbl = (label or "").strip() + if not lbl: + await db.execute( + "DELETE FROM silnt.payjoin_contact_labels WHERE contact_id = :cid AND labeler_user_id = :uid", + {"cid": contact_id, "uid": labeler_user_id}, + ) + return + existing = await db.fetchone( + "SELECT 1 FROM silnt.payjoin_contact_labels WHERE contact_id = :cid AND labeler_user_id = :uid", + {"cid": contact_id, "uid": labeler_user_id}, + ) + if existing: + await db.execute( + f"UPDATE silnt.payjoin_contact_labels SET label = :lbl, updated_at = {db.timestamp_now} " + "WHERE contact_id = :cid AND labeler_user_id = :uid", + {"lbl": lbl, "cid": contact_id, "uid": labeler_user_id}, + ) + else: + await db.execute( + "INSERT INTO silnt.payjoin_contact_labels (contact_id, labeler_user_id, label) " + "VALUES (:cid, :uid, :lbl)", + {"cid": contact_id, "uid": labeler_user_id, "lbl": lbl}, + ) + + +async def get_payjoin_contact_labels(labeler_user_id: str) -> dict: + """Map {contact_id: label} of this user's private labels.""" + rows = await db.fetchall( + "SELECT contact_id, label FROM silnt.payjoin_contact_labels WHERE labeler_user_id = :uid", + {"uid": labeler_user_id}, + ) + return {r["contact_id"]: r["label"] for r in rows} + + +async def list_payjoin_contacts(user_id: str) -> dict: + """All connections touching this user, grouped. Returns raw rows with the + counterparty_user_id annotated; the endpoint resolves usernames for display + (so usernames are never stored, only resolved on demand).""" + rows = await db.fetchall( + """SELECT * FROM silnt.payjoin_contacts + WHERE requester_user_id = :uid OR target_user_id = :uid + ORDER BY updated_at DESC""", + {"uid": user_id}, + ) + accepted, incoming, outgoing, declined = [], [], [], [] + for r in rows: + c = PayjoinContact(**r) + other_id = c.requester_user_id if c.target_user_id == user_id else c.target_user_id + d = c.dict() + d["counterparty_user_id"] = other_id + if c.status == "ACCEPTED": + accepted.append(d) + elif c.status == "PENDING" and c.target_user_id == user_id: + incoming.append(d) + elif c.status == "PENDING" and c.requester_user_id == user_id: + outgoing.append(d) + elif c.status == "DECLINED" and c.requester_user_id == user_id: + declined.append(d) + return {"accepted": accepted, "incoming": incoming, "outgoing": outgoing, "declined": declined} + + +async def list_accepted_contacts_with_ids(user_id: str) -> list[dict]: + """ACCEPTED connections: [{contact_id, user_id}] where user_id is the + counterparty. Lets the endpoint attach this user's private label + username.""" + rows = await db.fetchall( + """SELECT * FROM silnt.payjoin_contacts + WHERE status = 'ACCEPTED' AND (requester_user_id = :uid OR target_user_id = :uid)""", + {"uid": user_id}, + ) + out = [] + for r in rows: + c = PayjoinContact(**r) + other = c.target_user_id if c.requester_user_id == user_id else c.requester_user_id + out.append({"contact_id": c.id, "user_id": other}) + return out + + +async def list_accepted_contact_user_ids(user_id: str) -> list[str]: + """user_ids of this user's ACCEPTED connections (counterparties).""" + rows = await db.fetchall( + """SELECT * FROM silnt.payjoin_contacts + WHERE status = 'ACCEPTED' AND (requester_user_id = :uid OR target_user_id = :uid)""", + {"uid": user_id}, + ) + out = [] + for r in rows: + c = PayjoinContact(**r) + out.append(c.target_user_id if c.requester_user_id == user_id else c.requester_user_id) + return out + + +async def list_payjoin_requests_for_receiver( + receiver_user_id: str, status: Optional[str] = None +) -> list[PayjoinRequest]: + if status: + rows = await db.fetchall( + "SELECT * FROM silnt.payjoin_requests WHERE receiver_user_id = :uid " + "AND status = :st ORDER BY created_at DESC", + {"uid": receiver_user_id, "st": status}, + ) + else: + rows = await db.fetchall( + "SELECT * FROM silnt.payjoin_requests WHERE receiver_user_id = :uid " + "ORDER BY created_at DESC", + {"uid": receiver_user_id}, + ) + return [PayjoinRequest(**r) for r in rows] + + +async def list_payjoin_requests_for_sender(sender_user_id: str) -> list[PayjoinRequest]: + rows = await db.fetchall( + "SELECT * FROM silnt.payjoin_requests WHERE sender_user_id = :uid " + "ORDER BY created_at DESC", + {"uid": sender_user_id}, + ) + return [PayjoinRequest(**r) for r in rows] + + +async def update_payjoin_request(rid: str, **fields) -> Optional[PayjoinRequest]: + """ + Generic field updater. Always bumps updated_at. Pass only columns that exist. + e.g. update_payjoin_request(rid, status='CONTRIBUTED', psbt=..., fee_sats=...) + """ + if not fields: + return await get_payjoin_request(rid) + fields_sql = ", ".join(f"{k} = :{k}" for k in fields) + params = {**fields, "id": rid} + await db.execute( + f"UPDATE silnt.payjoin_requests SET {fields_sql}, " + f"updated_at = {db.timestamp_now} WHERE id = :id", + params, + ) + return await get_payjoin_request(rid) + +async def list_expired_payjoin_requests(now_ts: Optional[int] = None) -> list[PayjoinRequest]: + """Non-terminal requests past their expiry — for the sweep. expires_at is + Unix seconds (int).""" + now_ts = now_ts if now_ts is not None else int(time.time()) + rows = await db.fetchall( + "SELECT * FROM silnt.payjoin_requests " + "WHERE status IN ('PROPOSED','CONTRIBUTED') AND expires_at IS NOT NULL " + "AND expires_at < :now", + {"now": now_ts}, + ) + return [PayjoinRequest(**r) for r in rows] + +# ── SP send contacts (per-user private address book) ────────────────────────── +def _spc_hash(value: str) -> str: + import hashlib + return hashlib.sha256(value.strip().lower().encode()).hexdigest() + +def _classify_recipient(value: str) -> str: + """'bitmail' if it's a name@domain, else 'sp' (raw SP address).""" + return "bitmail" if "@" in (value or "") else "sp" + +def _decrypt_sp_contact_row(row: dict) -> dict: + out = dict(row) + if out.get("value"): + try: + out["value"] = _pj_decrypt(out["value"]) + except Exception: + pass # legacy/plaintext tolerance + return out + +async def create_sp_contact( + user_id: str, label: str, value: str, network: str +) -> "SpContact": + from .models import SpContact + value = (value or "").strip() + if not value: + raise ValueError("Recipient is required.") + label = (label or "").strip() or value + kind = _classify_recipient(value) + vhash = _spc_hash(value) + # Dedup within the same user AND network — the same recipient can legitimately + # be saved on more than one network, so it's not a cross-network duplicate. + existing = await db.fetchone( + "SELECT id FROM silnt.sp_contacts WHERE user_id = :uid AND network = :net AND value_sha256 = :h", + {"uid": user_id, "net": network, "h": vhash}, + ) + # A name must be unique within the user's per-network address book. Reject if + # some OTHER contact already uses this label (case-insensitive) — this same + # recipient keeping or changing its own label is fine (handled below). + label_owner = await db.fetchone( + "SELECT id FROM silnt.sp_contacts " + "WHERE user_id = :uid AND network = :net AND LOWER(label) = LOWER(:l)", + {"uid": user_id, "net": network, "l": label}, + ) + if label_owner and (not existing or label_owner["id"] != existing["id"]): + raise ValueError(f'A contact named "{label}" already exists.') + if existing: + await db.execute( + "UPDATE silnt.sp_contacts SET label = :l WHERE id = :id", + {"l": label, "id": existing["id"]}, + ) + return await get_sp_contact(existing["id"]) + cid = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO silnt.sp_contacts (id, user_id, network, label, kind, value, value_sha256) + VALUES (:id, :uid, :net, :label, :kind, :value, :h) + """, + {"id": cid, "uid": user_id, "net": network, "label": label, "kind": kind, + "value": _pj_encrypt(value), "h": vhash}, + ) + return await get_sp_contact(cid) + +async def get_sp_contact(cid: str) -> Optional["SpContact"]: + from .models import SpContact + row = await db.fetchone("SELECT * FROM silnt.sp_contacts WHERE id = :id", {"id": cid}) + return SpContact(**_decrypt_sp_contact_row(row)) if row else None + +async def list_sp_contacts(user_id: str, network: str) -> list: + from .models import SpContact + rows = await db.fetchall( + "SELECT * FROM silnt.sp_contacts WHERE user_id = :uid AND network = :net " + "ORDER BY last_used_at DESC NULLS LAST, label ASC", + {"uid": user_id, "net": network}, + ) + return [SpContact(**_decrypt_sp_contact_row(r)) for r in rows] + +async def update_sp_contact_label(cid: str, user_id: str, label: str) -> None: + label = (label or "").strip() + # Same uniqueness rule as create: a rename can't collide with another + # contact's name in the same user+network address book. + clash = await db.fetchone( + "SELECT id FROM silnt.sp_contacts " + "WHERE user_id = :uid AND id != :cid AND LOWER(label) = LOWER(:l) " + "AND network = (SELECT network FROM silnt.sp_contacts WHERE id = :cid)", + {"uid": user_id, "cid": cid, "l": label}, + ) + if clash: + raise ValueError(f'A contact named "{label}" already exists.') + await db.execute( + "UPDATE silnt.sp_contacts SET label = :l WHERE id = :id AND user_id = :uid", + {"l": label, "id": cid, "uid": user_id}, + ) + +async def touch_sp_contact(user_id: str, value: str, network: str) -> None: + """Bump last_used_at when a saved recipient is sent to (for ordering).""" + await db.execute( + "UPDATE silnt.sp_contacts SET last_used_at = :ts " + "WHERE user_id = :uid AND network = :net AND value_sha256 = :h", + {"ts": int(time.time()), "uid": user_id, "net": network, "h": _spc_hash(value)}, + ) + +async def delete_sp_contact(cid: str, user_id: str) -> None: + await db.execute( + "DELETE FROM silnt.sp_contacts WHERE id = :id AND user_id = :uid", + {"id": cid, "uid": user_id}, + ) + + +async def list_silnt_user_ids() -> list[str]: + """Distinct user_ids known to siLNt — anyone with a wallet, trusted device, + imported PayJoin descriptor, or BitMail request. Used by the admin Accounts + page to enumerate deletable users (scoped to siLNt users, not every LNbits + account).""" + ids: set[str] = set() + # (query, column-name-as-returned) — read by real column name, matching how + # the rest of this module reads db.fetchall rows (dict-like mappings). + for sql, col in ( + ('SELECT DISTINCT "user" FROM silnt.wallets WHERE "user" IS NOT NULL', "user"), + ("SELECT DISTINCT user_id FROM silnt.trusted_devices", "user_id"), + ("SELECT DISTINCT user_id FROM silnt.payjoin_descriptors", "user_id"), + ("SELECT DISTINCT user_id FROM silnt.bip353_requests", "user_id"), + ): + try: + rows = await db.fetchall(sql, {}) + except Exception: + continue # a table may be absent on older schemas — skip that source + for r in rows: + v = r[col] + if v: + ids.add(v) + return sorted(ids) + + +# ── Admin alerts ────────────────────────────────────────────────────────────── +async def create_admin_alert( + kind: str, title: str, detail: str = "", + severity: str = "warning", meta: Optional[str] = None, +) -> AdminAlert: + """Record an admin-visible alert (surfaced in the Admin console).""" + aid = urlsafe_short_hash() + now = int(time.time()) + await db.execute( + """INSERT INTO silnt.admin_alerts + (id, kind, severity, title, detail, meta, acknowledged, created_at) + VALUES (:id, :kind, :sev, :title, :detail, :meta, FALSE, :ts)""", + {"id": aid, "kind": kind, "sev": severity, "title": title, + "detail": detail, "meta": meta, "ts": now}, + ) + return AdminAlert(id=aid, kind=kind, severity=severity, title=title, + detail=detail, meta=meta, acknowledged=False, created_at=now) + + +async def list_admin_alerts(include_acknowledged: bool = False, limit: int = 100) -> list[AdminAlert]: + if include_acknowledged: + rows = await db.fetchall( + "SELECT * FROM silnt.admin_alerts ORDER BY created_at DESC LIMIT :lim", + {"lim": limit}, + ) + else: + rows = await db.fetchall( + """SELECT * FROM silnt.admin_alerts WHERE acknowledged = FALSE + ORDER BY created_at DESC LIMIT :lim""", + {"lim": limit}, + ) + return [AdminAlert(**dict(r)) for r in rows] + + +async def count_open_admin_alerts() -> int: + row = await db.fetchone( + "SELECT COUNT(*) AS c FROM silnt.admin_alerts WHERE acknowledged = FALSE" + ) + return int(row["c"]) if row else 0 + + +async def acknowledge_admin_alert(alert_id: str) -> bool: + result = await db.execute( + "UPDATE silnt.admin_alerts SET acknowledged = TRUE WHERE id = :id", + {"id": alert_id}, + ) + return (getattr(result, "rowcount", 0) or 0) > 0 + + +async def _delete_admin_alerts_where_meta(field: str, value: str) -> int: + """Delete admin alerts whose meta JSON has meta[field] == value. The wallet_id + and user_id an alert refers to live inside the meta JSON blob (there is no + column for them), so we parse each row rather than filter in SQL. Alert volume + is small, so scanning the table is fine.""" + if not value: + return 0 + rows = await db.fetchall("SELECT id, meta FROM silnt.admin_alerts") + removed = 0 + for r in rows: + raw = r["meta"] or "" + try: + meta = json.loads(raw) if raw else {} + except Exception: + continue + if meta.get(field) == value: + await db.execute( + "DELETE FROM silnt.admin_alerts WHERE id = :id", {"id": r["id"]} + ) + removed += 1 + return removed + + +async def delete_admin_alerts_for_wallet(wallet_id: str) -> int: + """Remove admin alerts raised for a wallet (matched via meta.wallet_id). Called + on wallet deletion so a removed wallet leaves no orphan alerts behind.""" + return await _delete_admin_alerts_where_meta("wallet_id", wallet_id) + + +async def delete_admin_alerts_for_user(user_id: str) -> int: + """Remove admin alerts raised for a user (matched via meta.user_id). Used when + wiping all of a user's siLNt data.""" + return await _delete_admin_alerts_where_meta("user_id", user_id) + + +async def get_issued_bitmail_sp_address(username: str) -> Optional[str]: + """Return the SP address siLNt recorded for an APPROVED BitMail issued on our + own domain, matched by final_username. None if we never issued that name. + Used to detect tampering: compare against what DNS resolves to at send time.""" + row = await db.fetchone( + """SELECT sp_address FROM silnt.bip353_requests + WHERE LOWER(final_username) = LOWER(:uname) AND status = 'approved' + ORDER BY processed_at DESC NULLS LAST LIMIT 1""", + {"uname": username}, + ) + return row["sp_address"] if row else None + + +async def list_approved_bitmails() -> list[dict]: + """Every APPROVED BitMail siLNt issued: its final_username, the SP address we + recorded, and the owning user_id/wallet_id. Used by the tamper sweep to + resolve each via DNS and compare against the recorded SP.""" + rows = await db.fetchall( + """SELECT final_username, sp_address, user_id, wallet_id + FROM silnt.bip353_requests + WHERE status = 'approved' AND final_username IS NOT NULL""", + ) + return [dict(r) for r in rows] + + +async def open_alert_exists_for(kind: str, key: str) -> bool: + """True if there's already an UNacknowledged alert of this kind whose meta + references `key` (e.g. a bitmail). Prevents the sweep from re-alerting the + same active tamper on every run — a new alert only fires once the admin + acknowledges the previous one (or the tamper clears).""" + rows = await db.fetchall( + """SELECT meta FROM silnt.admin_alerts + WHERE kind = :kind AND acknowledged = FALSE""", + {"kind": kind}, + ) + for r in rows: + m = r["meta"] or "" + if key and key in m: + return True + return False + + +async def tamper_signature_alerted(bitmail: str, resolved_sp: str) -> bool: + """True if we've ALREADY created an alert for this exact tamper — same + bitmail redirected to the same rogue SP address — regardless of whether the + admin has acknowledged it. This is the correct dedup for an ONGOING tamper: + dismissing the alert must NOT cause the next sweep to re-insert/re-notify. + A DIFFERENT rogue address (or a recurrence after the record was fixed) has a + different signature, so it still alerts.""" + rows = await db.fetchall( + "SELECT meta FROM silnt.admin_alerts WHERE kind = 'bitmail_tamper'", + ) + for r in rows: + m = r["meta"] or "" + try: + d = json.loads(m) if m else {} + except Exception: + continue + if (d.get("bitmail") == bitmail + and (d.get("resolved_sp") or "").lower() == (resolved_sp or "").lower()): + return True + return False + + +async def create_device_code( + user_id: str, code: str, device_id: str, + user_agent: Optional[str], ip: Optional[str], ttl_secs: int = 600, +) -> None: + """Store a hashed device-confirmation code (one active per user; latest + replaces any previous). The plaintext code is emailed, never stored.""" + import time as _t + code_hash = hashlib.sha256(code.encode()).hexdigest() + now = int(_t.time()) + await db.execute( + """INSERT INTO silnt.device_codes + (user_id, code_hash, device_id, user_agent, ip, expires_at, attempts) + VALUES (:uid, :ch, :did, :ua, :ip, :exp, 0) + ON CONFLICT (user_id) DO UPDATE SET + code_hash = :ch, device_id = :did, user_agent = :ua, + ip = :ip, expires_at = :exp, attempts = 0""", + {"uid": user_id, "ch": code_hash, "did": device_id, "ua": user_agent, + "ip": ip, "exp": now + ttl_secs}, + ) + + +async def verify_device_code(user_id: str, code: str, max_attempts: int = 5) -> Optional[dict]: + """ + Verify a device-confirmation code for the user. Returns the pending device + info dict {device_id, user_agent, ip} on success (and consumes the code), or + None on failure. Enforces expiry and an attempt cap: after `max_attempts` + wrong tries the code is invalidated (deleted), so a 6-digit code can't be + brute-forced. + """ + import time as _t + row = await db.fetchone( + "SELECT code_hash, device_id, user_agent, ip, expires_at, attempts " + "FROM silnt.device_codes WHERE user_id = :uid", + {"uid": user_id}, + ) + if not row: + return None + now = int(_t.time()) + if now > int(row["expires_at"]): + await db.execute("DELETE FROM silnt.device_codes WHERE user_id = :uid", {"uid": user_id}) + return None + if int(row["attempts"]) >= max_attempts: + await db.execute("DELETE FROM silnt.device_codes WHERE user_id = :uid", {"uid": user_id}) + return None + + supplied = hashlib.sha256((code or "").encode()).hexdigest() + if not hmac.compare_digest(supplied, row["code_hash"]): + await db.execute( + "UPDATE silnt.device_codes SET attempts = attempts + 1 WHERE user_id = :uid", + {"uid": user_id}, + ) + return None + + # Success — consume the code (single-use). + await db.execute("DELETE FROM silnt.device_codes WHERE user_id = :uid", {"uid": user_id}) + return { + "device_id": row["device_id"], + "user_agent": row["user_agent"], + "ip": row["ip"], + } + + +async def mark_self_revoke(user_id: str) -> None: + """Record that the user just revoked their own current device. The login + alert suppresses false positives within a short grace window after this — + otherwise the user's own immediate re-login (right after self-revoking) would + trigger a 'new device sign-in' email, which is confusing and not a break-in.""" + import time as _t + now = int(_t.time()) + await db.execute( + """INSERT INTO silnt.login_alerts (user_id, sig, last_alert_at) + VALUES (:uid, '__revoke_grace__', :now) + ON CONFLICT (user_id, sig) + DO UPDATE SET last_alert_at = :now""", + {"uid": user_id, "now": now}, + ) + + +async def in_self_revoke_grace(user_id: str, grace_secs: int = 300) -> bool: + """True if the user self-revoked within the last `grace_secs` (default 5 min).""" + import time as _t + row = await db.fetchone( + "SELECT last_alert_at FROM silnt.login_alerts WHERE user_id = :uid AND sig = '__revoke_grace__'", + {"uid": user_id}, + ) + if not row: + return False + return (int(_t.time()) - int(row["last_alert_at"])) < grace_secs + + +async def should_send_login_alert(user_id: str, sig: str, cooldown_secs: int = 43200) -> bool: + """ + Return True if we should send an unauthorized-device login alert for this + (user, device signature), and record that we're doing so. Deduplicated: once + an alert is sent for a signature, we won't send another for the same + signature until `cooldown_secs` has elapsed (default 12h). Prevents spamming + the user when an untrusted device repeatedly hits device-check (refreshes, + VPN flaps, dropped cookies). + """ + import time as _t + now = int(_t.time()) + row = await db.fetchone( + "SELECT last_alert_at FROM silnt.login_alerts WHERE user_id = :uid AND sig = :sig", + {"uid": user_id, "sig": sig}, + ) + if row and (now - int(row["last_alert_at"])) < cooldown_secs: + return False + await db.execute( + """INSERT INTO silnt.login_alerts (user_id, sig, last_alert_at) + VALUES (:uid, :sig, :now) + ON CONFLICT (user_id, sig) + DO UPDATE SET last_alert_at = :now""", + {"uid": user_id, "sig": sig, "now": now}, + ) + return True + + +async def open_tamper_notified(kind: str, key: str) -> bool: + """True if there's an open (unacknowledged) alert of this kind referencing + `key` that we've ALREADY sent an ntfy for (meta contains '"notified": true').""" + rows = await db.fetchall( + """SELECT meta FROM silnt.admin_alerts + WHERE kind = :kind AND acknowledged = FALSE""", + {"kind": kind}, + ) + for r in rows: + m = r["meta"] or "" + if key and key in m and '"notified": true' in m: + return True + return False -db = Database("ext_example") -# add your fnctions here +async def mark_tamper_notified(kind: str, key: str) -> None: + """Mark open alert(s) of this kind referencing `key` as notified, so the + sweep doesn't re-send the ntfy on subsequent runs while the tamper persists.""" + rows = await db.fetchall( + """SELECT id, meta FROM silnt.admin_alerts + WHERE kind = :kind AND acknowledged = FALSE""", + {"kind": kind}, + ) + for r in rows: + m = r["meta"] or "" + if not (key and key in m): + continue + try: + d = json.loads(m) if m else {} + except Exception: + d = {} + d["notified"] = True + await db.execute( + "UPDATE silnt.admin_alerts SET meta = :meta WHERE id = :id", + {"meta": json.dumps(d), "id": r["id"]}, + ) -# async def create_a_record(data: Example) -> createExample: -# example_id = urlsafe_short_hash() -# example = Example(id=example_id, **data.dict()) -# await db.insert("example.example", example) -# return example +async def resolve_open_alerts_for(kind: str, key: str) -> int: + """Auto-acknowledge open alert(s) of this kind referencing `key`. Used by the + tamper sweep when a previously-mismatched BitMail now resolves correctly (the + DNS record was fixed), so the stale alert clears itself. Returns count cleared.""" + rows = await db.fetchall( + """SELECT id, meta FROM silnt.admin_alerts + WHERE kind = :kind AND acknowledged = FALSE""", + {"kind": kind}, + ) + cleared = 0 + for r in rows: + m = r["meta"] or "" + if not (key and key in m): + continue + await db.execute( + "UPDATE silnt.admin_alerts SET acknowledged = TRUE WHERE id = :id", + {"id": r["id"]}, + ) + cleared += 1 + return cleared -# async def get_a_record(example_id: str) -> Optional[Example]: -# return await db.fetchone( -# "SELECT * FROM example.example WHERE id = :id", {"id": example_id}, Example -# ) diff --git a/helpers/address_resolver.py b/helpers/address_resolver.py new file mode 100644 index 0000000..6b042db --- /dev/null +++ b/helpers/address_resolver.py @@ -0,0 +1,128 @@ +import dns.resolver +import dns.message +import dns.query +import dns.name +import dns.rdatatype +import dns.flags +from http import HTTPStatus +from fastapi import HTTPException +from loguru import logger + +# DNSSEC-validating resolvers — both validate the full chain and set AD flag +DNSSEC_RESOLVERS = ["1.1.1.1", "8.8.8.8", "9.9.9.9"] + +# Authentic Data flag (RFC 4035) — set by resolver when DNSSEC validation passed +AD_FLAG = 0x0020 + + +def _query_txt_with_dnssec(qname: str) -> tuple[list[str], bool]: + qname_obj = dns.name.from_text(qname) + request = dns.message.make_query(qname_obj, dns.rdatatype.TXT, want_dnssec=True) + + last_error = None + for nameserver in DNSSEC_RESOLVERS: + try: + try: + response = dns.query.tcp(request, nameserver, timeout=10) + except Exception: + response = dns.query.udp(request, nameserver, timeout=10) + + dnssec_valid = bool(response.flags & AD_FLAG) + + records = [] + for rrset in response.answer: + if rrset.rdtype == dns.rdatatype.TXT: + for rdata in rrset: + records.append("".join(s.decode() for s in rdata.strings)) + + if records: + return records, dnssec_valid + + # NXDOMAIN → domain itself doesn't exist + if response.rcode() == dns.rcode.NXDOMAIN: + raise dns.resolver.NXDOMAIN() + + # ★ NEW: resolver answered successfully (NOERROR) but there are no + # TXT records → the name has no BIP-353 record. This is a definitive + # "not found", NOT a resolver failure. Raise NoAnswer so the caller + # maps it to 404 instead of falling through to "all resolvers failed". + if response.rcode() == dns.rcode.NOERROR: + raise dns.resolver.NoAnswer(response=response) + + # Any other RCODE (SERVFAIL etc.) → try the next resolver + last_error = Exception(f"RCODE {response.rcode()} from {nameserver}") + continue + + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): + raise + except Exception as e: + last_error = e + logger.warning(f"DNSSEC query to {nameserver} failed: {e}, trying next") + continue + + raise Exception(f"All DNSSEC resolvers failed. Last error: {last_error}") + + +def bip353_resolve(address: str) -> dict: + try: + user, domain = address.strip().split("@") + except ValueError: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Invalid BIP353 address format. Expected user@domain.com", + ) + + dns_domain = f"{user}.user._bitcoin-payment.{domain}" + + try: + records, dnssec_valid = _query_txt_with_dnssec(dns_domain) + + if not records: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"No TXT record found for {dns_domain}", + ) + + if not dnssec_valid: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=( + f"DNSSEC validation failed for {dns_domain}. " + "The domain must have valid DNSSEC signatures. " + "Resolving this address would be unsafe." + ), + ) + + result = records[0] + + if not result.startswith("bitcoin:"): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"TXT record does not contain a valid bitcoin: URI: {result}", + ) + + # logger.info(f"BIP353 resolved {address} → {result} (DNSSEC validated)") + return { + "address": address, + "dns_domain": dns_domain, + "result": result, + "dnssec": True, + } + + except HTTPException: + raise + except dns.resolver.NXDOMAIN: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Domain not found: {dns_domain}", + ) + except dns.resolver.NoAnswer: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"No TXT record found for {address}", + ) + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, + detail=f"DNS resolution failed: {str(exc)}", + ) diff --git a/helpers/bip353_cloudflare.py b/helpers/bip353_cloudflare.py new file mode 100644 index 0000000..7505d81 --- /dev/null +++ b/helpers/bip353_cloudflare.py @@ -0,0 +1,148 @@ +""" +BIP-353 DNS record management via Cloudflare API. +Creates/updates TXT records of the form: + {username}.user._bitcoin-payment.{domain} → "bitcoin:?sp={sp_address}" +""" + +import httpx +from loguru import logger +from ..crud import get_cloudflare_config + +CF_API = "https://api.cloudflare.com/client/v4" + + +class CloudflareError(Exception): + pass + + +def _headers(api_token: str) -> dict: + return { + "Authorization": f"Bearer {api_token}", + "Content-Type": "application/json", + } + + +def bip353_record_name(username: str, domain: str) -> str: + """Full DNS name for a BIP-353 TXT record.""" + return f"{username}.user._bitcoin-payment.{domain}" + + +def bip353_record_content(sp_address: str) -> str: + """TXT record content per BIP-353 spec.""" + return f"bitcoin:?sp={sp_address}" + + +async def get_zone_domain(api_token: str, zone_id: str) -> str: + """Fetch the domain name for a Cloudflare zone.""" + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get( + f"{CF_API}/zones/{zone_id}", + headers=_headers(api_token), + ) + data = resp.json() + if not data.get("success"): + raise CloudflareError(f"Failed to fetch zone: {data.get('errors')}") + return data["result"]["name"] + + +async def find_existing_record( + api_token: str, zone_id: str, record_name: str +) -> str | None: + """Return the record ID if a TXT record already exists, else None.""" + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get( + f"{CF_API}/zones/{zone_id}/dns_records", + headers=_headers(api_token), + params={"type": "TXT", "name": record_name}, + ) + data = resp.json() + if not data.get("success"): + raise CloudflareError(f"Failed to query DNS records: {data.get('errors')}") + results = data.get("result", []) + return results[0]["id"] if results else None + + +async def create_bip353_record( + api_token: str, + zone_id: str, + domain: str, + username: str, + sp_address: str, + ttl: int = 300, +) -> dict: + """ + Create or update a BIP-353 TXT record in Cloudflare. + Returns {"record_name": ..., "hr_address": ..., "action": "created"|"updated"} + """ + record_name = bip353_record_name(username, domain) + content = bip353_record_content(sp_address) + + existing_id = await find_existing_record(api_token, zone_id, record_name) + + payload = { + "type": "TXT", + "name": record_name, + "content": '"' + content + '"', + "ttl": ttl, + "comment": "BIP-353 Silent Payment address — managed by Thrilla", + } + + async with httpx.AsyncClient(timeout=10.0) as client: + if existing_id: + # Update existing record + resp = await client.put( + f"{CF_API}/zones/{zone_id}/dns_records/{existing_id}", + headers=_headers(api_token), + json=payload, + ) + action = "updated" + else: + # Create new record + resp = await client.post( + f"{CF_API}/zones/{zone_id}/dns_records", + headers=_headers(api_token), + json=payload, + ) + action = "created" + + data = resp.json() + if not data.get("success"): + errors = data.get("errors", []) + msg = errors[0].get("message", "Unknown error") if errors else "Unknown error" + raise CloudflareError(f"Cloudflare DNS {action} failed: {msg}") + + hr_address = f"{username}@{domain}" + logger.info(f"BIP-353 record {action}: {record_name} → {content}") + + return { + "record_name": record_name, + "hr_address": hr_address, + "domain": domain, + "action": action, + } + + +async def delete_bip353_record( + api_token: str, + zone_id: str, + domain: str, + username: str, +) -> bool: + """Delete a BIP-353 TXT record. Returns True if deleted, False if not found.""" + record_name = bip353_record_name(username, domain) + record_id = await find_existing_record(api_token, zone_id, record_name) + + if not record_id: + return False + + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.delete( + f"{CF_API}/zones/{zone_id}/dns_records/{record_id}", + headers=_headers(api_token), + ) + data = resp.json() + if not data.get("success"): + raise CloudflareError(f"Failed to delete record: {data.get('errors')}") + + logger.info(f"BIP-353 record deleted: {record_name}") + return True diff --git a/helpers/curve.py b/helpers/curve.py new file mode 100644 index 0000000..4160efc --- /dev/null +++ b/helpers/curve.py @@ -0,0 +1,212 @@ +import random +import hmac +import os +import hashlib +from enum import Enum +from typing import Optional, Tuple +from binascii import unhexlify +from ecdsa import SigningKey, SECP256k1 + +# Elliptic curve parameters +p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F +n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 +G = ( + 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, + 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8, +) + +# Points are tuples of X and Y coordinates +# the point at infinity is represented by the None keyword +Point = Tuple[int, int] + + +class Encoding(Enum): + """Enumeration type to list the various supported encodings.""" + + BECH32 = 1 + BECH32M = 2 + + +CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +BECH32M_CONST = 0x2BC830A3 + + +# Get x coordinate from a point +def x(P: Point) -> int: + return P[0] + + +# Get y coordinate from a point +def y(P: Point) -> int: + return P[1] + + +# ser256(p): serializes the integer p as a 32-byte sequence, most significant byte first. +def ser256(p: int) -> bytes: + return p.to_bytes(32, "big") + + +# serP(P): serializes the coordinate pair P = (x,y) as a byte sequence using SEC1's compressed form: +# (0x02 or 0x03) || ser256(x), where the header byte depends on the parity of the omitted Y coordinate. +def serP(P: Point) -> bytes: + x_bytes = ser256(int_from_bytes(bytes_from_point(P))) + prefix = ( + b"\x02" if has_even_y(P) else b"\x03" + ) # Determine the parity of y to choose the prefix + return prefix + x_bytes + + +# Get an int from bytes +def int_from_bytes(b: bytes) -> int: + return int.from_bytes(b, byteorder="big") + + +# Point addition +def point_add(P1: Optional[Point], P2: Optional[Point]) -> Optional[Point]: + if P1 is None: + return P2 + if P2 is None: + return P1 + if (x(P1) == x(P2)) and (y(P1) != y(P2)): + return None + if P1 == P2: + lam = (3 * x(P1) * x(P1) * pow(2 * y(P1), p - 2, p)) % p + else: + lam = ((y(P2) - y(P1)) * pow(x(P2) - x(P1), p - 2, p)) % p + x3 = (lam * lam - x(P1) - x(P2)) % p + return x3, (lam * (x(P1) - x3) - y(P1)) % p + + +# Point multiplication +def point_mul(P: Optional[Point], d: int) -> Optional[Point]: + R = None + for i in range(256): + if (d >> i) & 1: + R = point_add(R, P) + P = point_add(P, P) + return R + + +# Check if a point has even y coordinate +def has_even_y(P: Point) -> bool: + return y(P) % 2 == 0 + + +# Get bytes from an int +def bytes_from_int(a: int) -> bytes: + return a.to_bytes(32, byteorder="big") + + +# Get bytes from a point +def bytes_from_point(P: Point) -> bytes: + return bytes_from_int(x(P)) + + +def convertbits( + data: bytes, frombits: int, tobits: int, pad: bool = True +) -> Optional[list[int]]: + """General power-of-2 base conversion.""" + acc = 0 + bits = 0 + ret = [] + maxv = (1 << tobits) - 1 + max_acc = (1 << (frombits + tobits - 1)) - 1 + for value in data: + if value < 0 or (value >> frombits): + return None + acc = ((acc << frombits) | value) & max_acc + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if pad: + if bits: + ret.append((acc << (tobits - bits)) & maxv) + elif bits >= frombits or ((acc << (tobits - bits)) & maxv): + return None + return ret + + +# Generate public key (as a point) from an int +def pubkey_point_gen_from_int(seckey: int) -> Point: + P = point_mul(G, seckey) + assert P is not None + return P + + +def bech32_decode( + bech: str, +) -> Tuple[Optional[str], Optional[list[int]], Optional[Encoding]]: + """Validate a Bech32m string, and determine HRP and data.""" + if (any(ord(x) < 33 or ord(x) > 126 for x in bech)) or ( + bech.lower() != bech and bech.upper() != bech + ): + return (None, None, None) + bech = bech.lower() + pos = bech.rfind("1") + if pos < 1 or pos + 7 > len(bech) or len(bech) > 117: + return (None, None, None) + if not all(x in CHARSET for x in bech[pos + 1 :]): + return (None, None, None) + hrp = bech[:pos] + data = [CHARSET.find(x) for x in bech[pos + 1 :]] + spec = bech32_verify_checksum(hrp, data) + if spec is None: + return (None, None, None) + return (hrp, data[:-6], spec) + + +def bech32_polymod(values: list[int]) -> int: + """Internal function that computes the Bech32 checksum.""" + generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3] + chk = 1 + for value in values: + top = chk >> 25 + chk = (chk & 0x1FFFFFF) << 5 ^ value + for i in range(5): + chk ^= generator[i] if ((top >> i) & 1) else 0 + return chk + + +def bech32_hrp_expand(hrp: str) -> list[int]: + """Expand the HRP into values for checksum computation.""" + return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] + + +def bech32_verify_checksum(hrp: str, data: list[int]) -> Optional[Encoding]: + """Verify a checksum given HRP and converted data characters.""" + const = bech32_polymod(bech32_hrp_expand(hrp) + data) + if const == 1: + return Encoding.BECH32 + if const == BECH32M_CONST: + return Encoding.BECH32M + return None + + +def decode(hrp: str, addr: str) -> Tuple[Optional[int], Optional[list[int]]]: + """Decode a segwit address.""" + hrpgot, data, _ = bech32_decode(addr) + if hrpgot != hrp: + return (None, None) + decoded = convertbits(data[1:], 5, 8, False) if data else None + if decoded is None or len(decoded) < 2 or len(decoded) > 71: + return (None, None) + if data[0] > 16: + return (None, None) + if data[0] == 0 and len(decoded) != 66: + return (None, None) + return (data[0], decoded) + + +def bech32_create_checksum(hrp: str, data: list[int], spec: Encoding) -> list[int]: + """Compute the checksum values given HRP and data.""" + values = bech32_hrp_expand(hrp) + data + const = BECH32M_CONST if spec == Encoding.BECH32M else 1 + polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ const + return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] + + +def bech32_encode(hrp: str, data: list[int], spec: Encoding) -> str: + """Compute a Bech32 string given HRP and data values.""" + combined = data + bech32_create_checksum(hrp, data, spec) + return hrp + "1" + "".join([CHARSET[d] for d in combined]) diff --git a/helpers/device_auth.py b/helpers/device_auth.py new file mode 100644 index 0000000..6be5aee --- /dev/null +++ b/helpers/device_auth.py @@ -0,0 +1,173 @@ +import base64 +import hmac +import hashlib +import json +import time +import re +import os +from typing import Optional +from fastapi import Cookie, Depends, HTTPException, Request, Response +from http import HTTPStatus +from loguru import logger +from lnbits.settings import settings as lnbits_settings +# Variant for admin-key routes: +from lnbits.decorators import require_admin_key +from lnbits.decorators import WalletTypeInfo, require_invoice_key +from ..crud import get_trusted_device, touch_trusted_device + +MAX_TRUSTED_DEVICES_PER_USER = 5 +DEVICE_COOKIE_PREFIX = "silnt_device_id_" +DEVICE_COOKIE_MAX_AGE = 365 * 24 * 3600 # 1 year +DEVICE_CONFIRM_TOKEN_TTL = 3600 # 1 hour +DEVICE_COOKIE_DOMAIN = os.environ.get("SILNT_DEVICE_COOKIE_DOMAIN", "").strip() or None +DEVICE_HEADER = "X-Silnt-Device" + +def _is_thrilla_request(request: Request) -> bool: + # Thrilla's fetch wrapper sends this header on every call. The LNbits-native + # extension page does not. + return request.headers.get("X-Thrilla-Client") == "1" + +def _safe_user_id_for_cookie(user_id: str) -> str: + """Sanitize user_id for cookie name (allow only [a-zA-Z0-9_-]).""" + return re.sub(r"[^A-Za-z0-9_\-]", "", user_id or "")[:64] + + +def cookie_name_for_user(user_id: str) -> str: + """Per-user cookie name. Multiple users on the same browser each get + their own trust cookie.""" + return DEVICE_COOKIE_PREFIX + _safe_user_id_for_cookie(user_id) + +def read_device_id(request: Request, user_id: str) -> str | None: + """Device id from the per-user cookie (web) OR the X-Silnt-Device header + (native app, where cross-site cookies aren't reliably stored).""" + cookie_value = request.cookies.get(cookie_name_for_user(user_id)) + if cookie_value: + return cookie_value + hdr = request.headers.get(DEVICE_HEADER) + return hdr.strip() if hdr else None + +def set_device_cookie(response: Response, user_id: str, device_id: str) -> None: + """Set the per-user HttpOnly device_id cookie.""" + response.set_cookie( + key = cookie_name_for_user(user_id), + value = device_id, + max_age = DEVICE_COOKIE_MAX_AGE, + httponly = True, + secure = True, + samesite = "lax", + path = "/", + domain= DEVICE_COOKIE_DOMAIN + ) + +def clear_device_cookie(response: Response, user_id: str) -> None: + """Clear a user's device cookie (used when revoking own device).""" + response.delete_cookie(cookie_name_for_user(user_id), path="/", domain=DEVICE_COOKIE_DOMAIN) + +def _hmac_sign(payload: str) -> str: + key = (lnbits_settings.auth_secret_key or "").encode() + if not key: + raise RuntimeError("LNBITS_AUTH_SECRET_KEY is not set — required for device tokens.") + return base64.urlsafe_b64encode( + hmac.new(key, payload.encode(), hashlib.sha256).digest() + ).rstrip(b"=").decode() + + +def make_device_confirm_token(user_id: str, device_id: str, ua: str, ip: str) -> str: + """Build a signed token for the email-confirmation link.""" + payload = { + "user_id": user_id, + "device_id": device_id, + "ua": (ua or "")[:512], + "ip": (ip or "")[:64], + "exp": int(time.time()) + DEVICE_CONFIRM_TOKEN_TTL, + } + body = base64.urlsafe_b64encode( + json.dumps(payload, separators=(",", ":")).encode() + ).rstrip(b"=").decode() + sig = _hmac_sign(body) + return f"{body}.{sig}" + + +def verify_device_confirm_token(token: str) -> Optional[dict]: + try: + body, sig = token.split(".", 1) + except ValueError: + return None + expected = _hmac_sign(body) + if not hmac.compare_digest(sig, expected): + return None + try: + # Re-pad base64 + padding = "=" * (-len(body) % 4) + payload = json.loads(base64.urlsafe_b64decode(body + padding).decode()) + except Exception: + return None + if payload.get("exp", 0) < int(time.time()): + return None + return payload + + +def get_client_ip(request: Request) -> str: + """Best-effort IP extraction — checks X-Forwarded-For first (Caddy proxy).""" + xff = request.headers.get("x-forwarded-for", "") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else "" + + +async def require_trusted_device( + request: Request, + key_info: WalletTypeInfo = Depends(require_invoice_key), +) -> WalletTypeInfo: + + if not _is_thrilla_request(request): + return key_info + user_id = key_info.wallet.user + # cookie_value = request.cookies.get(cookie_name_for_user(user_id)) + cookie_value = read_device_id(request, user_id) + if not cookie_value: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="device-not-trusted: no device cookie present", + ) + + device = await get_trusted_device(user_id, cookie_value) + if not device: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="device-not-trusted: device not in trusted list", + ) + + try: + await touch_trusted_device(user_id, cookie_value) + except Exception as e: + logger.warning(f"touch_trusted_device failed: {e}") + + return key_info + + +async def require_trusted_device_admin( + request: Request, + key_info: WalletTypeInfo = Depends(require_admin_key), +) -> WalletTypeInfo: + if not _is_thrilla_request(request): + return key_info + user_id = key_info.wallet.user + # cookie_value = request.cookies.get(cookie_name_for_user(user_id)) + cookie_value = read_device_id(request, user_id) + if not cookie_value: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="device-not-trusted: no device cookie present", + ) + device = await get_trusted_device(user_id, cookie_value) + if not device: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="device-not-trusted: device not in trusted list", + ) + try: + await touch_trusted_device(user_id, cookie_value) + except Exception as e: + logger.warning(f"touch_trusted_device failed: {e}") + return key_info \ No newline at end of file diff --git a/helpers/dust_check.py b/helpers/dust_check.py new file mode 100644 index 0000000..7ed9274 --- /dev/null +++ b/helpers/dust_check.py @@ -0,0 +1,127 @@ +import httpx +from loguru import logger +from ..crud import ( + get_backend_config, + get_wallet_unspent_utxos_for_dust_check, + get_wallet_owned_outpoints, + is_own_sent_tx, + update_utxo_dust_flag, + set_utxo_freeze_auto, + clear_utxo_freeze_auto, + normalize_unfrozen_override, + get_utxo_freeze_reason, + get_effective_dust_threshold, + get_silnt_wallet +) + +BIP352_CHANGE_LABEL_INDEX = 1 + +async def _funding_tx_inputs_match_owned( + txid: str, + owned_outpoints: set[tuple[str, int]], + mempool_base: str, +) -> bool: + base = mempool_base.rstrip("/") + try: + async with httpx.AsyncClient(timeout=10.0) as c: + r = await c.get(f"{base}/api/tx/{txid}") + if r.status_code != 200: + return False + tx = r.json() + + input_outpoints: set[tuple[str, int]] = set() + for vin in tx.get("vin", []): + in_txid = vin.get("txid") + in_vout = vin.get("vout") + if in_txid is None or in_vout is None: + continue + try: + input_outpoints.add((in_txid, int(in_vout))) + except (TypeError, ValueError): + continue + + return bool(input_outpoints & owned_outpoints) + except Exception as e: + logger.warning(f"Dust check: could not fetch tx {txid}: {e}") + return False + + +async def evaluate_dust_for_wallet(wallet_id: str) -> int: + """ + Reconcile suspected_dust + auto-freeze state for every unspent UTXO. + + Idempotent: produces the correct (dust, frozen, freeze_reason) tuple for + each UTXO regardless of prior state. Manual freezes are never touched. + + Returns the number of UTXOs that transitioned from non-dust to dust on + this run (useful for the "flagged N new dust UTXO(s)" log line). + """ + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + return 0 + threshold = await get_effective_dust_threshold(wallet.user, wallet.network) + backend = await get_backend_config(wallet.network) + mempool = backend.mempool_url or "https://mempool.space" + + utxos = await get_wallet_unspent_utxos_for_dust_check(wallet_id) + owned_outpoints = await get_wallet_owned_outpoints(wallet_id) + + logger.info( + f"Dust eval: wallet={wallet_id} threshold={threshold} " + f"unspent_count={len(utxos)} owned_outpoints={len(owned_outpoints)}" + ) + + newly_flagged = 0 + + for u in utxos: + amount = int(u.get("amount") or 0) + utxo_txid = u.get("txid") or "" + utxo_vout = int(u.get("vout") or 0) + current_dust = bool(u.get("suspected_dust") or False) + + if not utxo_txid: + continue + + # Determine the correct classification for this UTXO + if amount > threshold: + should_be_dust = False + else: + if u.get("label_index") == BIP352_CHANGE_LABEL_INDEX: + should_be_dust = False + else: + is_self_send = await is_own_sent_tx(wallet_id, utxo_txid) + if not is_self_send: + is_self_send = await _funding_tx_inputs_match_owned( + utxo_txid, owned_outpoints, mempool + ) + should_be_dust = not is_self_send + + logger.info( + f" utxo {utxo_txid[:12]}:{utxo_vout} amount={amount} " + f"current_dust={current_dust} should_be_dust={should_be_dust}" + ) + + # Reconcile state — always make the DB match the classification + if should_be_dust: + # Ensure suspected_dust=TRUE AND freeze_reason='auto' + if not current_dust: + await update_utxo_dust_flag(utxo_txid, utxo_vout, True) + newly_flagged += 1 + # Always ensure auto-freeze is set when classified as dust. + # set_utxo_freeze_auto only writes freeze_reason='auto'; it does + # NOT clobber existing manual freezes (the WHERE in the UPDATE + # filters by current state, or the helper should — see note). + current_reason = await get_utxo_freeze_reason(utxo_txid, utxo_vout) + if current_reason not in ("manual", "manual_unfrozen"): + await set_utxo_freeze_auto(utxo_txid, utxo_vout) + else: + # Ensure suspected_dust=FALSE; release any auto-freeze (leave manual) + if current_dust: + await update_utxo_dust_flag(utxo_txid, utxo_vout, False) + await clear_utxo_freeze_auto(utxo_txid, utxo_vout) + await normalize_unfrozen_override(utxo_txid, utxo_vout) + + if newly_flagged: + logger.info(f"Wallet {wallet_id}: flagged {newly_flagged} new dust UTXO(s)") + + return newly_flagged \ No newline at end of file diff --git a/helpers/electrum_client.py b/helpers/electrum_client.py new file mode 100644 index 0000000..9c29c98 --- /dev/null +++ b/helpers/electrum_client.py @@ -0,0 +1,243 @@ +""" +Stage 0 spike: prove siLNt's backend can talk to a Fulcrum/Electrum server and +fetch correct UTXO/balance data for a known address. + +Self-contained. No siLNt/LNbits imports, no DB, no xpub chains. Just connectivity ++ the Electrum line protocol + correct scripthash computation. + +Run: + python electrum_client.py [--tls] + +Examples: + python electrum_client.py 192.0.2.10 50001 tb1q... # plain TCP + python electrum_client.py fulcrum.example.net 50002 tb1q... --tls + +What it does: + 1. Opens a TCP (or TLS) socket to host:port. + 2. server.version handshake (newline-delimited JSON-RPC). + 3. Computes the Electrum "scripthash" for the given P2WPKH (bech32 bc1q/tb1q) + address: scriptPubKey -> sha256 -> REVERSE bytes -> hex. + 4. Calls blockchain.scripthash.get_balance and .listunspent. + 5. Prints balance + UTXOs so you can eyeball against an explorer. + +If this prints the right balance/UTXOs for a known signet address, the entire +PayJoin feature is de-risked — everything downstream is conventional. + +NOTE (production, not this spike): plain TCP over the internet leaks which +scripthashes you query to any on-path observer. Use TLS (:50002) or a tunnel +(WireGuard/Tailscale/SSH) in production. This client supports TLS via --tls. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import socket +import ssl +import sys + + +# ── bech32 / segwit decode (BIP-173) — minimal, vendored to stay standalone ─── +CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + +def _bech32_polymod(values: list[int]) -> int: + generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3] + chk = 1 + for v in values: + top = chk >> 25 + chk = (chk & 0x1FFFFFF) << 5 ^ v + for i in range(5): + chk ^= generator[i] if ((top >> i) & 1) else 0 + return chk + + +def _bech32_hrp_expand(hrp: str) -> list[int]: + return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] + + +def _bech32_verify_checksum(hrp: str, data: list[int]) -> str | None: + """Return 'bech32' or 'bech32m' depending on which checksum validates.""" + const = _bech32_polymod(_bech32_hrp_expand(hrp) + data) + if const == 1: + return "bech32" + if const == 0x2BC830A3: + return "bech32m" + return None + + +def _bech32_decode(addr: str) -> tuple[str | None, list[int] | None, str | None]: + if any(ord(c) < 33 or ord(c) > 126 for c in addr): + return None, None, None + if addr.lower() != addr and addr.upper() != addr: + return None, None, None + addr = addr.lower() + pos = addr.rfind("1") + if pos < 1 or pos + 7 > len(addr) or len(addr) > 90: + return None, None, None + hrp = addr[:pos] + if any(c not in CHARSET for c in addr[pos + 1:]): + return None, None, None + data = [CHARSET.find(c) for c in addr[pos + 1:]] + spec = _bech32_verify_checksum(hrp, data) + if spec is None: + return None, None, None + return hrp, data[:-6], spec + + +def _convertbits(data: list[int], frombits: int, tobits: int, pad: bool = True) -> list[int] | None: + acc = 0 + bits = 0 + ret = [] + maxv = (1 << tobits) - 1 + max_acc = (1 << (frombits + tobits - 1)) - 1 + for value in data: + if value < 0 or (value >> frombits): + return None + acc = ((acc << frombits) | value) & max_acc + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if pad and bits: + ret.append((acc << (tobits - bits)) & maxv) + elif bits >= frombits or ((acc << (tobits - bits)) & maxv): + return None + return ret + + +def address_to_scriptpubkey(addr: str) -> bytes: + """ + Convert a segwit bech32/bech32m address to its scriptPubKey bytes. + Supports v0 P2WPKH (20-byte) / P2WSH (32-byte) and v1 P2TR (32-byte). + For this spike we mainly care about P2WPKH (BIP-84, bc1q/tb1q). + """ + hrp, data, spec = _bech32_decode(addr) + if hrp is None or data is None: + raise ValueError(f"Not a valid bech32 address: {addr}") + if hrp not in ("bc", "tb", "bcrt"): + raise ValueError(f"Unexpected HRP {hrp!r} (expected bc/tb/bcrt)") + witver = data[0] + prog = _convertbits(data[1:], 5, 8, False) + if prog is None: + raise ValueError("Invalid witness program padding") + prog = bytes(prog) + # checksum spec must match version: v0 -> bech32, v1+ -> bech32m + if witver == 0 and spec != "bech32": + raise ValueError("v0 address must use bech32 checksum") + if witver >= 1 and spec != "bech32m": + raise ValueError("v1+ address must use bech32m checksum") + if witver == 0: + if len(prog) == 20: + return bytes([0x00, 0x14]) + prog # P2WPKH + if len(prog) == 32: + return bytes([0x00, 0x20]) + prog # P2WSH + raise ValueError(f"Invalid v0 program length {len(prog)}") + if witver == 1 and len(prog) == 32: + return bytes([0x51, 0x20]) + prog # P2TR + raise ValueError(f"Unsupported witver/len: v{witver}/{len(prog)}") + + +def electrum_scripthash(addr: str) -> str: + """ + Electrum scripthash = sha256(scriptPubKey), BYTE-REVERSED, hex. + The reversal is the classic gotcha — Electrum uses little-endian here. + """ + spk = address_to_scriptpubkey(addr) + h = hashlib.sha256(spk).digest() + return h[::-1].hex() + + +# ── minimal Electrum JSON-RPC line client ───────────────────────────────────── +class ElectrumClient: + def __init__(self, host: str, port: int, use_tls: bool = False, timeout: float = 10.0): + self.host = host + self.port = port + self.use_tls = use_tls + self.timeout = timeout + self._sock: socket.socket | None = None + self._buf = b"" + self._id = 0 + + def connect(self) -> None: + raw = socket.create_connection((self.host, self.port), timeout=self.timeout) + if self.use_tls: + # Fulcrum's self-signed cert is common; for a spike we don't verify. + # In production, pin/verify the cert or tunnel instead. + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + raw = ctx.wrap_socket(raw, server_hostname=self.host) + raw.settimeout(self.timeout) + self._sock = raw + + def close(self) -> None: + if self._sock: + try: + self._sock.close() + finally: + self._sock = None + + def _call(self, method: str, params: list) -> dict: + assert self._sock is not None, "not connected" + self._id += 1 + req = {"id": self._id, "method": method, "params": params} + self._sock.sendall((json.dumps(req) + "\n").encode()) + # read until we get a full line containing our id + while True: + if b"\n" in self._buf: + line, self._buf = self._buf.split(b"\n", 1) + if not line.strip(): + continue + msg = json.loads(line.decode()) + if msg.get("id") == self._id: + return msg + # ignore notifications / other ids in this simple spike + continue + chunk = self._sock.recv(4096) + if not chunk: + raise ConnectionError("server closed connection") + self._buf += chunk + + def server_version(self) -> list: + r = self._call("server.version", ["siLNt-spike", "1.4"]) + if "error" in r and r["error"]: + raise RuntimeError(f"server.version error: {r['error']}") + return r.get("result") + + def get_balance(self, scripthash: str) -> dict: + r = self._call("blockchain.scripthash.get_balance", [scripthash]) + if "error" in r and r["error"]: + raise RuntimeError(f"get_balance error: {r['error']}") + return r.get("result", {}) + + def list_unspent(self, scripthash: str) -> list: + r = self._call("blockchain.scripthash.listunspent", [scripthash]) + if "error" in r and r["error"]: + raise RuntimeError(f"listunspent error: {r['error']}") + return r.get("result", []) + + def broadcast(self, tx_hex: str) -> str: + """blockchain.transaction.broadcast -> returns txid (or raises with the + node's reject reason, e.g. 'witness program hash mismatch').""" + r = self._call("blockchain.transaction.broadcast", [tx_hex]) + if "error" in r and r["error"]: + raise RuntimeError(f"broadcast rejected: {r['error']}") + return r.get("result", "") + + def get_transaction(self, txid: str, verbose: bool = True) -> dict: + r = self._call("blockchain.transaction.get", [txid, verbose]) + if "error" in r and r["error"]: + raise RuntimeError(f"get tx error: {r['error']}") + return r.get("result", {}) + + def server_height(self) -> int: + """Current chain tip height as Fulcrum sees it (blockchain.headers.subscribe + returns the tip header with its height). Used for health/sync checks.""" + r = self._call("blockchain.headers.subscribe", []) + if "error" in r and r["error"]: + raise RuntimeError(f"headers.subscribe error: {r['error']}") + res = r.get("result", {}) + # result is {height, hex} for the current tip + return int(res.get("height", 0)) diff --git a/helpers/email_verification.py b/helpers/email_verification.py new file mode 100644 index 0000000..b8b6cc5 --- /dev/null +++ b/helpers/email_verification.py @@ -0,0 +1,297 @@ +""" +Email-verified registration for the siLNt extension. + +Flow: + 1. User submits registration → validate inputs, hash password + 2. Generate a signed token containing {username, email, password_hash, ts} + and email it as a clickable link — NO account is created yet + 3. User clicks the link → decode/validate token (incl. 1-hour TTL) + 4. Create LNbits Account with a fresh uuid4 id + bcrypt password hash + 5. Enable LNBITS_USER_DEFAULT_EXTENSIONS on the new account, marking paid + extensions as already-paid so they bypass the payment requirement + +This guarantees the account is only created after email ownership is proven, +and that siLNt is fully active on first login (paid or not). +""" + +import base64 +import json +import time +from http import HTTPStatus +from typing import Optional +from uuid import uuid4 +from fastapi import HTTPException, Request +from loguru import logger +from pydantic import BaseModel + +from lnbits.core.crud import ( + get_account_by_username_or_email, + create_account +) +from lnbits.core.crud.extensions import ( + create_user_extension, + update_user_extension, + get_user_extension, +) +from lnbits.core.models import Account +from lnbits.core.models.extensions import UserExtension, UserExtensionInfo +from lnbits.core.services.notifications import send_email_notification +from lnbits.helpers import encrypt_internal_message, decrypt_internal_message +from lnbits.settings import settings, AuthMethods + + +# Token lifetime — how long verification link stays valid +VERIFICATION_TOKEN_TTL_SECONDS = 60 * 60 # 1 hour + + +class RegistrationRequest(BaseModel): + username: str + email: str + password: str + + +class VerifyRegistrationRequest(BaseModel): + token: str + + +# ── Token helpers ───────────────────────────────────────────────────────────── + +def _generate_verification_token( + username: str, email: str, password: str +) -> str: + """Sign a token carrying the pending registration data.""" + payload = { + "kind": "register", + "username": username, + "email": email, + "password": password, + "ts": int(time.time()), + } + payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + enc = encrypt_internal_message(payload_json) + if not enc: + raise RuntimeError("Cannot generate verification token.") + return "verify_" + base64.urlsafe_b64encode(enc.encode()).decode().rstrip("=") + + +def _decode_verification_token(token: str) -> Optional[dict]: + """Decode and validate a verification token. Returns None if invalid/expired.""" + if not token.startswith("verify_"): + return None + try: + b64 = token[7:] + padded = b64 + "=" * (-len(b64) % 4) + enc = base64.urlsafe_b64decode(padded).decode() + payload_json = decrypt_internal_message(enc) + if not payload_json: + return None + payload = json.loads(payload_json) + except Exception as exc: + logger.warning(f"Invalid verification token: {exc}") + return None + + if payload.get("kind") != "register": + return None + + ts = payload.get("ts", 0) + if int(time.time()) - int(ts) > VERIFICATION_TOKEN_TTL_SECONDS: + return None + + return payload + + +# ── Start registration: send email ──────────────────────────────────────────── + +async def start_registration( + data: RegistrationRequest, request: Request +) -> dict: + """ + Validate inputs, check uniqueness, hash the password, generate a + verification token, and email the link. Does NOT create the account yet. + """ + if not settings.is_auth_method_allowed(AuthMethods.username_and_password): + raise HTTPException( + HTTPStatus.FORBIDDEN, "Username/password auth disabled." + ) + + username = data.username.strip() + email = data.email.strip().lower() + + if len(username) < 3 or len(username) > 32: + raise HTTPException(HTTPStatus.BAD_REQUEST, "Username must be 3–32 chars.") + if "@" not in email: + raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.") + if len(data.password) < 8: + raise HTTPException(HTTPStatus.BAD_REQUEST, "Password too short (min 8).") + + existing = await get_account_by_username_or_email(username) \ + or await get_account_by_username_or_email(email) + if existing: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "Username or email already in use.", + ) + + try: + token = _generate_verification_token(username, email, data.password) + except Exception as exc: + logger.error(f"Token generation failed: {exc}") + raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not start registration.") + + # Resolve frontend origin so the link points at the Thrilla domain + origin = request.headers.get("origin") or request.headers.get("referer") or "" + if not origin: + origin = f"https://{request.headers.get('host', '')}" + origin = origin.rstrip("/") + if origin.count("/") > 2: + parts = origin.split("/") + origin = "/".join(parts[:3]) + verify_url = f"{origin}/verify?token={token}" + + subject = "Thrilla — Verify your email" + body = ( + f"Hi {username},\n\n" + f"Thanks for registering for Thrilla. Click the link below to " + f"activate your account:\n\n" + f"{verify_url}\n\n" + f"This link expires in {VERIFICATION_TOKEN_TTL_SECONDS // 60} minutes.\n\n" + f"If you didn't request this, you can safely ignore this email.\n\n" + f"— Thrilla" + ) + + if not settings.lnbits_email_notifications_enabled: + raise HTTPException( + HTTPStatus.INTERNAL_SERVER_ERROR, + "Email notifications disabled — cannot complete registration.", + ) + + try: + result = await send_email_notification( + to_emails=[email], + message=body, + subject=subject, + ) + if result.get("status") != "ok": + logger.error(f"Email send failed: {result.get('message')}") + raise HTTPException( + HTTPStatus.BAD_GATEWAY, + "Could not send verification email — please try again later.", + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Email send raised: {exc}") + raise HTTPException( + HTTPStatus.BAD_GATEWAY, + "Could not send verification email — please try again later.", + ) + + logger.info(f"Verification email sent to {email} for pending username {username}") + return { + "success": True, + "message": "Verification email sent. Check your inbox to complete registration.", + "email": email, + } + + +# ── Complete registration: create account + enable extensions ───────────────── + +async def complete_registration(token: str) -> dict: + payload = _decode_verification_token(token) + if not payload: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "Verification link is invalid or has expired. Please register again.", + ) + + username = payload["username"] + email = payload["email"] + raw_password = payload["password"] # ← raw password from the token + + existing = await get_account_by_username_or_email(username) \ + or await get_account_by_username_or_email(email) + if existing: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "Username or email is no longer available — please register again with a different one.", + ) + + try: + account = Account( + id = uuid4().hex, + username = username, + email = email, + ) + account.hash_password(raw_password) # ← LNbits-correct, id-bound hashing + await create_account(account) # ← the working creation path + account_id = account.id + logger.info(f"Email-verified account created: {username} ({email}) id={account_id}") + + # Enable default extensions (paid bypass) — unchanged + try: + default_exts = settings.lnbits_user_default_extensions or [] + for ext_id in default_exts: + user_ext = UserExtension( + user=account_id, + extension=ext_id, + active=True, + extra=UserExtensionInfo( + paid_to_enable=True, + payment_hash_to_enable="default_enabled", + ), + ) + existing_ext = await get_user_extension(account_id, ext_id) + if existing_ext: + await update_user_extension(user_extension=user_ext) + else: + await create_user_extension(user_extension=user_ext) + logger.info(f"Enabled default extension '{ext_id}' for {account_id} (paid bypass)") + except Exception as exc: + logger.warning(f"Could not enable default extensions for {account_id}: {exc}") + + except HTTPException: + raise + except Exception as exc: + logger.error(f"Account creation failed for {username}: {exc}") + raise HTTPException( + HTTPStatus.INTERNAL_SERVER_ERROR, + "Failed to create account. Please try again.", + ) + + # ── Send welcome email — non-fatal if it fails ──────────────────────────── + try: + welcome_subject = "Welcome to Thrilla" + welcome_body = ( + f"Hi {username},\n\n" + f"Your Thrilla account is now active. You can sign in any time at the " + f"URL below using your username and password.\n\n" + f"What you can do with Thrilla:\n" + f" • Create Silent Payment (BIP-352) wallets — private by default\n" + f" • Scan the chain for incoming payments via your BlindBit oracle\n" + f" • Send to sp1… / bc1q… / BIP-353 (alice@domain) recipients\n" + f" • Register a BIP-353 human-readable address for your wallet\n\n" + f"Wallet keys are stored ONLY on your device — never on the server. " + f"Keep your mnemonic safe; without it your funds can't be recovered.\n\n" + f"If you ever need to reset your password, use the 'Forgot password' " + f"link on the sign-in screen.\n\n" + f"— Thrilla" + ) + if settings.lnbits_email_notifications_enabled: + res = await send_email_notification( + to_emails=[email], + message=welcome_body, + subject=welcome_subject, + ) + if res.get("status") == "ok": + logger.info(f"Welcome email sent to {email}") + else: + logger.warning(f"Welcome email send returned: {res.get('message')}") + except Exception as exc: + # Welcome email failure must not block account activation + logger.warning(f"Could not send welcome email to {email}: {exc}") + + return { + "success": True, + "username": username, + "email": email, + } diff --git a/helpers/fee_rates_backend.py b/helpers/fee_rates_backend.py new file mode 100644 index 0000000..bd24815 --- /dev/null +++ b/helpers/fee_rates_backend.py @@ -0,0 +1,81 @@ +# ══════════════════════════════════════════════════════════════════════════════ +# Live fee rates from the configured mempool (blindbit_config.mempool_url) +# ══════════════════════════════════════════════════════════════════════════════ +# Proxies GET {mempool_url}/api/v1/fees/recommended so the client gets fee tiers +# without calling mempool.space directly (the app's CSP is connect-src 'self', so +# a direct browser→mempool call would be blocked — this MUST be server-side). +# +# Mempool returns: +# {fastestFee, halfHourFee, hourFee, economyFee, minimumFee} (sat/vB) +# We pass those through, plus a sane fallback if the mempool is unreachable. +# ══════════════════════════════════════════════════════════════════════════════ + +import httpx +import time +from http import HTTPStatus +from fastapi import Depends, HTTPException +from loguru import logger +from ..crud import get_backend_config, DEFAULT_CONFIG_NETWORK + +# Fallback tiers if the mempool can't be reached (signet/regtest or outage). +# 1 sat/vB across the board is safe for test networks and won't block a send. +_FALLBACK = { + "fastestFee": 2, "halfHourFee": 2, "hourFee": 1, "economyFee": 1, "minimumFee": 1, +} + +_CG_URL = "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd" + +# Simple in-process cache: (rate, fetched_at) +_rate_cache = {"rate": 0.0, "ts": 0.0} +_CACHE_TTL = 180 # seconds (3 min) + +async def get_btc_usd_rate() -> float: + """Return BTC price in USD from CoinGecko, cached. 0.0 on any failure.""" + now = time.time() + if _rate_cache["rate"] > 0 and (now - _rate_cache["ts"]) < _CACHE_TTL: + return _rate_cache["rate"] + try: + async with httpx.AsyncClient(timeout=10.0) as c: + r = await c.get(_CG_URL, headers={"accept": "application/json"}) + if r.status_code != 200: + logger.warning(f"CoinGecko rate → {r.status_code}") + return _rate_cache["rate"] # serve stale if we have it, else 0 + data = r.json() + rate = float(data.get("bitcoin", {}).get("usd", 0) or 0) + if rate > 0: + _rate_cache["rate"] = rate + _rate_cache["ts"] = now + return rate + except Exception as e: + logger.warning(f"CoinGecko rate fetch failed: {e}") + return _rate_cache["rate"] # stale or 0 + +async def get_recommended_fees(network: str = DEFAULT_CONFIG_NETWORK) -> dict: + """ + Fetch recommended fee tiers (sat/vB) from the configured mempool. Returns the + mempool shape, or a safe fallback on any error. Never raises — a fee lookup + failing should not block the user; they can still send at the fallback rate. + """ + backend = await get_backend_config(network) + base = (backend.mempool_url or "https://mempool.space").rstrip("/") + url = f"{base}/api/v1/fees/recommended" + try: + async with httpx.AsyncClient(timeout=10.0, verify=False) as c: + r = await c.get(url) + if r.status_code != 200: + logger.warning(f"fee lookup {url} → {r.status_code}; using fallback") + return {**_FALLBACK, "source": "fallback"} + data = r.json() + # Validate the expected keys exist; fall back if the shape is odd. + tiers = {} + for k in ("fastestFee", "halfHourFee", "hourFee", "economyFee", "minimumFee"): + v = data.get(k) + if not isinstance(v, (int, float)) or v <= 0: + logger.warning(f"fee lookup missing/invalid '{k}'; using fallback") + return {**_FALLBACK, "source": "fallback"} + tiers[k] = int(v) + tiers["source"] = "mempool" + return tiers + except Exception as e: + logger.warning(f"fee lookup failed ({url}): {e}; using fallback") + return {**_FALLBACK, "source": "fallback"} \ No newline at end of file diff --git a/helpers/forgot_password.py b/helpers/forgot_password.py new file mode 100644 index 0000000..076294d --- /dev/null +++ b/helpers/forgot_password.py @@ -0,0 +1,114 @@ +""" +Forgot-password flow for the siLNt extension. + +Generates LNbits-compatible reset keys (same algorithm as the admin endpoint +in user_api.py) and emails them as a clickable link to the user's registered +email address. +""" + +import base64 +import json +import time +from http import HTTPStatus + +from fastapi import APIRouter, HTTPException, Request +from loguru import logger +from pydantic import BaseModel + +from lnbits.core.crud import get_account_by_email +from lnbits.core.services.notifications import send_email_notification +from lnbits.helpers import encrypt_internal_message +from lnbits.settings import settings + +def _generate_reset_key(user_id: str) -> str: + """Generate the same kind of signed reset key LNbits's admin endpoint produces.""" + reset_data = ["reset", user_id, int(time.time())] + reset_data_json = json.dumps(reset_data, separators=(",", ":"), ensure_ascii=False) + enc = encrypt_internal_message(reset_data_json) + if not enc: + raise RuntimeError("Cannot generate reset key.") + reset_key_b64 = base64.b64encode(enc.encode()).decode() + return f"reset_key_{reset_key_b64}" + +async def request_password_reset(email: str, request: Request) -> dict: + """ + Look up account by email, generate reset key, email the link. + Always returns success-style response — never reveals whether the + email exists (prevents email enumeration). + All exceptions caught at the top level to guarantee a dict response. + """ + generic = { + "success": True, + "message": "If an account with that email exists, a reset link has been sent.", + } + + try: + # ── Look up account ─────────────────────────────────────────────────── + account = None + try: + account = await get_account_by_email(email) + except Exception as exc: + logger.warning(f"Forgot-password lookup failed for {email}: {exc}") + return generic + + if not account: + return generic + if account.id == settings.super_user: + logger.warning("Forgot-password attempted for superuser — ignoring.") + return generic + if not account.email: + logger.warning(f"Account {account.id} has no email on file.") + return generic + + # ── Generate reset key ──────────────────────────────────────────────── + try: + reset_key = _generate_reset_key(account.id) + except Exception as exc: + logger.error(f"Reset key generation failed: {exc}") + return generic + + # ── Build reset URL from request origin ─────────────────────────────── + origin = request.headers.get("origin") or request.headers.get("referer") or "" + if not origin: + origin = f"https://{request.headers.get('host', '')}" + origin = origin.rstrip("/") + # If referer has a path, strip it down to origin + if origin.count("/") > 2: + parts = origin.split("/") + origin = "/".join(parts[:3]) + reset_url = f"{origin}/reset?key={reset_key}" + + # ── Compose email ───────────────────────────────────────────────────── + subject = "Thrilla — Password reset request" + body = ( + f"Hi {account.username or 'there'},\n\n" + f"Someone (hopefully you) requested a password reset for your account.\n\n" + f"Click this link to set a new password:\n{reset_url}\n\n" + f"If you didn't request this, you can safely ignore this email.\n\n" + f"— Thrilla" + ) + + # ── Send ────────────────────────────────────────────────────────────── + if not settings.lnbits_email_notifications_enabled: + logger.error("Password reset requested but email notifications disabled in LNbits.") + return generic + + try: + result = await send_email_notification( + to_emails=[account.email], + message=body, + subject=subject, + ) + if result.get("status") != "ok": + logger.error(f"Failed to send reset email: {result.get('message')}") + return generic + logger.info(f"Password reset email sent to {account.email}") + except Exception as exc: + logger.error(f"Failed to send reset email to {account.email}: {exc}") + return generic + + return generic + + except Exception as exc: + logger.error(f"Unexpected error in forgot-password flow: {exc}", exc_info=True) + return generic \ No newline at end of file diff --git a/helpers/payjoin_merge.py b/helpers/payjoin_merge.py new file mode 100644 index 0000000..c855702 --- /dev/null +++ b/helpers/payjoin_merge.py @@ -0,0 +1,137 @@ +""" +PayJoin merge builder (helper) — builds the final UNSIGNED two-party PayJoin +PSBT from descriptors. Descriptor-based (not zpub+fp): derives addresses/keys and +key-origins straight from the output descriptors, so PSBT BIP32_DERIVATION matches +exactly what each wallet declared (the thing that made Sparrow recognize inputs). + +Model (sender-pays-fee, receiver is payee): + payment output = amount + R (to receiver's payment address) + sender change = S - amount - fee + unsigned; both parties sign their own input independently; siLNt combines. +""" + +from __future__ import annotations + +from embit import script +from embit.descriptor import Descriptor +from embit.networks import NETWORKS +from embit.psbt import PSBT, DerivationPath +from embit.transaction import Transaction, TransactionInput, TransactionOutput + +DUST = 546 +P2WPKH_IN_VB = 68 + + +def _net(network: str): + n = network.lower() + if n == "mainnet": + return NETWORKS["main"] + if n == "regtest": + return NETWORKS["regtest"] + return NETWORKS["test"] + + +def _spk_bytes(spk): + return spk.data if hasattr(spk, "data") else bytes(spk) + + +def _strip_checksum_merge(descriptor: str) -> str: + """Remove BIP-380 '#checksum' and repair mangled/single-chain multipath so a + verbatim Sparrow export (or a client-mangled '//*') parses + derives both + chains.""" + s = (descriptor or "").strip() + if "#" in s: + s = s[: s.rindex("#")].strip() + if "//*" in s: + s = s.replace("//*", "/<0;1>/*") + elif "/<0;1>/*" not in s and "/0/*" in s: + s = s.replace("/0/*", "/<0;1>/*") + return s + + +def _key_and_origin(descriptor: str, chain: int, index: int): + """Derive (pubkey, scriptPubKey, DerivationPath) for one input/output from a + descriptor at chain/index. Uses the descriptor's own key-origin so the PSBT + derivation matches the signer's wallet.""" + d = Descriptor.from_string(_strip_checksum_merge(descriptor)) + # derive the concrete key at branch=chain, index + dd = d.derive(index, branch_index=chain) + # dd.keys[0].key is an HDKey (extended key, serializes to 78 bytes). The PSBT + # BIP32_DERIVATION map MUST be keyed by the bare 33-byte compressed pubkey, so + # take HDKey.key -> embit.ec.PublicKey. (Using the HDKey directly produces a + # malformed PSBT key: Sparrow rejects "key type must be one byte plus pub key".) + hd = dd.keys[0].key # HDKey + key = hd.key # embit.ec.PublicKey (33-byte compressed) + spk = dd.script_pubkey() # Script (p2wpkh) + # origin: master fingerprint + full path (account_path + chain + index) + ko = d.keys[0].origin + full_path = list(ko.derivation) + [chain, index] + return key, spk, DerivationPath(ko.fingerprint, full_path) + + +def build_merged_payjoin( + sender_descriptor: str, sender_inputs: list[dict], + receiver_descriptor: str, receiver_input: dict, + network: str, destination: str, amount: int, fee_rate: float, + sender_change_index: int = 0, +) -> dict: + metas = [] # (TransactionInput, pubkey, spk, value, DerivationPath) + S = 0 + for u in sender_inputs: + key, spk, dp = _key_and_origin(sender_descriptor, int(u["chain"]), int(u["index"])) + vin = TransactionInput(bytes.fromhex(u["txid"]), int(u["vout"])) + vin.sequence = 0xFFFFFFFD + metas.append((vin, key, spk, int(u["value"]), dp)) + S += int(u["value"]) + + rk, rspk, rdp = _key_and_origin(receiver_descriptor, + int(receiver_input["chain"]), int(receiver_input["index"])) + rvin = TransactionInput(bytes.fromhex(receiver_input["txid"]), int(receiver_input["vout"])) + rvin.sequence = 0xFFFFFFFD + R = int(receiver_input["value"]) + metas.append((rvin, rk, rspk, R, rdp)) + + n_in = len(sender_inputs) + 1 + vsize = int(10 + P2WPKH_IN_VB * n_in + 31 * 2) + fee = max(1, round(vsize * fee_rate)) + sender_change = S - amount - fee + if sender_change < 0: + raise ValueError(f"Sender can't cover amount+fee: have {S}, need {amount + fee}") + + pay_spk = script.address_to_scriptpubkey(destination) + payment_value = amount + R + tx_outputs = [TransactionOutput(payment_value, pay_spk)] + out_meta = [None] + + if sender_change >= DUST: + ckey, cspk, cdp = _key_and_origin(sender_descriptor, 1, sender_change_index) + tx_outputs.append(TransactionOutput(sender_change, cspk)) + out_meta.append((ckey, cdp)) + else: + fee += max(0, sender_change) + sender_change = 0 + + # BIP-69 ordering + in_idx = sorted(range(len(metas)), key=lambda i: (metas[i][0].txid.hex(), metas[i][0].vout)) + metas = [metas[i] for i in in_idx] + out_idx = sorted(range(len(tx_outputs)), + key=lambda i: (tx_outputs[i].value, _spk_bytes(tx_outputs[i].script_pubkey).hex())) + tx_outputs = [tx_outputs[i] for i in out_idx] + out_meta = [out_meta[i] for i in out_idx] + + tx = Transaction(version=2, vin=[m[0] for m in metas], vout=tx_outputs) + psbt = PSBT(tx) + for i, (vin, key, spk, val, dp) in enumerate(metas): + psbt.inputs[i].witness_utxo = TransactionOutput(val, spk) + psbt.inputs[i].bip32_derivations[key] = dp + for i, m in enumerate(out_meta): + if m is not None: + key, dp = m + psbt.outputs[i].bip32_derivations[key] = dp + + return { + "psbt_base64": psbt.to_string(), + "sender_in": S, "receiver_in": R, + "payment_value": payment_value, "amount": amount, + "fee": fee, "sender_change": sender_change, "vsize": vsize, "n_inputs": n_in, + } \ No newline at end of file diff --git a/helpers/payjoin_wallet.py b/helpers/payjoin_wallet.py new file mode 100644 index 0000000..490a685 --- /dev/null +++ b/helpers/payjoin_wallet.py @@ -0,0 +1,287 @@ +""" +Stage 1 — watch-only BIP-84 wallet for PayJoin: import a zpub, derive the +receive (0/*) and change (1/*) address chains, and sync UTXOs/balance from +Fulcrum (via the Stage-0 ElectrumClient). + +siLNt holds ONLY the zpub. No seed, no private keys. This module discovers +spendable UTXOs and records, per UTXO, the (chain, index) so that Stage 2 can +emit correct PSBT BIP32_DERIVATION fields for the external signer (Sparrow). + +Derivation approach verified against BIP-84 canonical test vectors +(mnemonic "abandon abandon ... about"): zpub m/84'/0'/0' → + 0/0 bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu + 1/0 bc1q8c6fshw2dlwun7ekn9qwf37cu2rn755upcp6el + +Uses embit (already a siLNt dependency). Pairs with electrum_client.py. + +Assumptions (per decisions): +- Import format: zpub (BIP-84 account extended pubkey). Standard origin assumed: + m/84'/'/0', where coin = 0 mainnet, 1 signet/testnet. +- Chains: receive (0/*) AND change (1/*). Gap limit 20. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from embit import bip32, script +from embit.networks import NETWORKS +from .electrum_client import ElectrumClient, electrum_scripthash +import base58 + +GAP_LIMIT = 20 +RECEIVE_CHAIN = 0 +CHANGE_CHAIN = 1 + + +_SLIP132_TO_STD = { + # zpub (mainnet BIP-84) -> xpub + bytes.fromhex("04b24746"): bytes.fromhex("0488b21e"), + # vpub (testnet/signet BIP-84) -> tpub + bytes.fromhex("045f1cf6"): bytes.fromhex("043587cf"), + # already-standard pass through + bytes.fromhex("0488b21e"): bytes.fromhex("0488b21e"), + bytes.fromhex("043587cf"): bytes.fromhex("043587cf"), +} + + +def normalize_account_xpub(zpub: str) -> str: + """Re-version a zpub/vpub/xpub/tpub to the standard xpub/tpub embit accepts. + Lossless: only the version prefix changes, key+chaincode are untouched.""" + raw = base58.b58decode_check(zpub) + ver, rest = raw[:4], raw[4:] + std = _SLIP132_TO_STD.get(ver) + if std is None: + raise ValueError(f"Unsupported extended-key version {ver.hex()} (expected zpub/vpub/xpub/tpub)") + return base58.b58encode_check(std + rest).decode() + + +def coin_type_for(network: str) -> int: + return 0 if network.lower() == "mainnet" else 1 + + +def hrp_for(network: str) -> str: + n = network.lower() + if n == "mainnet": + return "bc" + if n == "regtest": + return "bcrt" + return "tb" # signet/testnet + + +@dataclass +class DerivedAddress: + chain: int # 0 receive, 1 change + index: int + address: str + pubkey_hex: str # 33-byte compressed pubkey (for PSBT BIP32_DERIVATION) + + +@dataclass +class WatchUtxo: + txid: str + vout: int + value: int + height: int + address: str + chain: int + index: int + pubkey_hex: str # owning key's compressed pubkey + + +@dataclass +class SyncResult: + network: str + addresses: list[DerivedAddress] = field(default_factory=list) + utxos: list[WatchUtxo] = field(default_factory=list) + confirmed_sats: int = 0 + unconfirmed_sats: int = 0 + + +# ── address derivation ──────────────────────────────────────────────────────── +def _account_key(zpub: str): + """Return an embit HDKey for the account-level extended PUBLIC key.""" + std = normalize_account_xpub(zpub) + return bip32.HDKey.from_string(std) + + +def derive_address(acct_key, network: str, chain: int, index: int) -> DerivedAddress: + """Derive one P2WPKH address at / from the account pubkey.""" + child = acct_key.derive([chain, index]) # non-hardened public CKD + pub = child.key # embit PublicKey + spk = script.p2wpkh(pub) # OP_0 + net = NETWORKS["main"] if network.lower() == "mainnet" else ( + NETWORKS["regtest"] if network.lower() == "regtest" else NETWORKS["test"] + ) + addr = spk.address(net) + return DerivedAddress( + chain=chain, + index=index, + address=addr, + pubkey_hex=pub.serialize().hex(), + ) + + +def _descriptor_net(network: str): + n = network.lower() + if n == "mainnet": + return NETWORKS["main"] + if n == "regtest": + return NETWORKS["regtest"] + return NETWORKS["test"] + + +def strip_descriptor_checksum(descriptor: str) -> str: + """Remove the BIP-380 '#checksum' suffix (and surrounding whitespace) so + embit's Descriptor.from_string can parse a Sparrow export pasted verbatim. + The checksum is always a trailing '#' + 8 chars; xpubs/paths never contain + '#', so cutting at the last '#' is safe. We keep the user's original string + for STORAGE; this is only for parsing.""" + s = (descriptor or "").strip() + if "#" in s: + s = s[: s.rindex("#")].strip() + return s + + +def _normalize_multipath(s: str) -> str: + """Repair a mangled/single-chain key-path to canonical '<0;1>' multipath so + both receive (0) and change (1) chains derive. Some clients eat the literal + '<0;1>' (it has '<' '>') leaving '//*'; others export single '/0/*'.""" + if "//*" in s: + s = s.replace("//*", "/<0;1>/*") + elif "/<0;1>/*" not in s and "/0/*" in s: + s = s.replace("/0/*", "/<0;1>/*") + return s + + +def _parse_descriptor_obj(descriptor: str): + """Single choke point: strip checksum, normalize multipath, then parse.""" + from embit.descriptor import Descriptor + return Descriptor.from_string(_normalize_multipath(strip_descriptor_checksum(descriptor))) + + +def _looks_like_descriptor(s: str) -> bool: + s = s.strip() + return "(" in s or s.lower().startswith(("wpkh", "pkh", "sh", "wsh", "tr")) + + +def derive_descriptor_address(descriptor: str, network: str, chain: int, index: int) -> DerivedAddress: + """Derive a P2WPKH address at / straight from an output + descriptor (handles the wpkh([fp/path]xpub/<0;1>/*) form). Uses the + descriptor's own derivation so addresses match the declaring wallet.""" + d = _parse_descriptor_obj(descriptor) + dd = d.derive(index, branch_index=chain) + hd = dd.keys[0].key # HDKey + pub = hd.key # embit.ec.PublicKey (33-byte compressed) + addr = dd.address(_descriptor_net(network)) + return DerivedAddress( + chain=chain, index=index, address=addr, + pubkey_hex=pub.serialize().hex(), + ) + + +# ── sync ────────────────────────────────────────────────────────────────────── +def _sync_chain(client: ElectrumClient, derive_fn, network: str, chain: int, + gap_limit: int = GAP_LIMIT) -> tuple[list[DerivedAddress], list[WatchUtxo], int, int]: + """ + Walk one chain (receive or change) until `gap_limit` consecutive unused + addresses are seen. `derive_fn(network, chain, index) -> DerivedAddress`. + Collect UTXOs from all used addresses. + """ + addrs: list[DerivedAddress] = [] + utxos: list[WatchUtxo] = [] + confirmed = unconfirmed = 0 + + consecutive_unused = 0 + index = 0 + while consecutive_unused < gap_limit: + da = derive_fn(network, chain, index) + sh = electrum_scripthash(da.address) + bal = client.get_balance(sh) + c = int(bal.get("confirmed", 0)) + u = int(bal.get("unconfirmed", 0)) + # An address counts as used if it currently holds funds OR has ever had + # history. get_balance only reflects current funds, so also peek history + # presence via listunspent (cheap) and treat any UTXO as "used". For a + # fully-spent-but-previously-used address, listunspent is empty and + # get_balance is 0 — we'd treat it as unused. That is acceptable for a + # PayJoin spend wallet (we only care about spendable UTXOs); the gap + # limit still advances past gaps. If strict used-detection is needed, + # swap to blockchain.scripthash.get_history. + unspent = client.list_unspent(sh) + used = bool(unspent) or c > 0 or u > 0 + + if used: + consecutive_unused = 0 + addrs.append(da) + confirmed += c + unconfirmed += u + for x in unspent: + utxos.append(WatchUtxo( + txid=x.get("tx_hash"), + vout=int(x.get("tx_pos")), + value=int(x.get("value")), + height=int(x.get("height", 0)), + address=da.address, + chain=chain, + index=index, + pubkey_hex=da.pubkey_hex, + )) + else: + consecutive_unused += 1 + + index += 1 + + return addrs, utxos, confirmed, unconfirmed + + +def next_unused_receive_index(descriptor_or_zpub: str, network: str, host: str, port: int, + use_tls: bool = False, gap_limit: int = GAP_LIMIT) -> int: + """ + Return the next unused RECEIVE-chain (chain 0) index for a descriptor, by + syncing and taking one past the highest used receive index. Avoids address + reuse when issuing a payment address (e.g. PayJoin accept). Returns 0 if the + receive chain has no history yet. + """ + res = sync_wallet(descriptor_or_zpub, network, host, port, + use_tls=use_tls, gap_limit=gap_limit) + used_recv = [a.index for a in res.addresses if a.chain == RECEIVE_CHAIN] + return (max(used_recv) + 1) if used_recv else 0 + + +def sync_wallet(descriptor_or_zpub: str, network: str, host: str, port: int, + use_tls: bool = False, gap_limit: int = GAP_LIMIT) -> SyncResult: + """ + Full watch-only sync: derive + scan receive and change chains via Fulcrum. + Accepts EITHER an output descriptor (wpkh([fp/path]xpub/<0;1>/*)) — the path + the endpoint uses — OR a bare zpub/vpub (the standalone CLI test path). + """ + result = SyncResult(network=network) + + s = (descriptor_or_zpub or "").strip() + if not s: + raise ValueError("Empty descriptor/xpub passed to sync_wallet " + "(stored descriptor missing or blank).") + + if _looks_like_descriptor(s): + desc = s + def derive_fn(net, chain, index): + return derive_descriptor_address(desc, net, chain, index) + else: + acct_key = _account_key(s) + def derive_fn(net, chain, index): + return derive_address(acct_key, net, chain, index) + + client = ElectrumClient(host, port, use_tls=use_tls) + try: + client.connect() + client.server_version() + for chain in (RECEIVE_CHAIN, CHANGE_CHAIN): + addrs, utxos, c, u = _sync_chain(client, derive_fn, network, chain, gap_limit) + result.addresses.extend(addrs) + result.utxos.extend(utxos) + result.confirmed_sats += c + result.unconfirmed_sats += u + finally: + client.close() + + return result diff --git a/helpers/psbt_combine.py b/helpers/psbt_combine.py new file mode 100644 index 0000000..928d7d5 --- /dev/null +++ b/helpers/psbt_combine.py @@ -0,0 +1,80 @@ +""" +PSBT combine + finalize for P2WPKH (the step siLNt's coordinator owns). + +Two parties each sign the SAME unsigned merged PSBT in their own Sparrow, each +exporting a PSBT whose inputs carry partial_sigs only for THEIR input(s). This: + 1. combines all partial_sigs into one PSBT (manual merge, no embit .update), + 2. finalizes each P2WPKH input -> final_scriptwitness = [sig, pubkey], + 3. serializes the network transaction hex. + +Avoids embit's Script.__eq__ trap: never use `x in (None, {}, ...)` on embit +objects (that calls Script.__eq__ which does self.data == other.data and throws +on None). Use `is None` / len() checks only. + +Usage: + python psbt_combine.py [...] + +All PSBTs must be signed copies of the SAME unsigned tx. +""" + +from __future__ import annotations + +import sys + +from embit.psbt import PSBT +from embit.transaction import Witness + + +def combine_and_finalize(psbt_b64_list: list[str]) -> dict: + if not psbt_b64_list: + raise ValueError("need at least one PSBT") + + base = PSBT.from_string(psbt_b64_list[0]) + n = len(base.inputs) + + # combine: merge partial_sigs from every PSBT + for extra_b64 in psbt_b64_list[1:]: + other = PSBT.from_string(extra_b64) + if len(other.inputs) != n: + raise ValueError("PSBTs have different input counts - not the same tx") + for i in range(n): + src = getattr(other.inputs[i], "partial_sigs", None) + if src: + if getattr(base.inputs[i], "partial_sigs", None) is None: + base.inputs[i].partial_sigs = type(src)() + for pub, sig in src.items(): + base.inputs[i].partial_sigs[pub] = sig + if base.inputs[i].witness_utxo is None and other.inputs[i].witness_utxo is not None: + base.inputs[i].witness_utxo = other.inputs[i].witness_utxo + + sig_report = [] + for i in range(n): + ps = getattr(base.inputs[i], "partial_sigs", None) + sig_report.append((i, bool(ps))) + + missing = [i for i, ok in sig_report if not ok] + if missing: + return { + "n_inputs": n, + "sig_report": sig_report, + "finalized": False, + "finalize_error": f"inputs missing partial_sigs: {missing}", + "combined_psbt": base.to_string(), + } + + # finalize each P2WPKH input: witness = [sig, pubkey] + tx = base.tx + for i in range(n): + ps = base.inputs[i].partial_sigs + pub, sig = next(iter(ps.items())) + wit = Witness([sig, pub.serialize()]) + base.inputs[i].final_scriptwitness = wit + tx.vin[i].witness = wit + + tx_hex = tx.serialize().hex() + return { + "n_inputs": n, + "sig_report": sig_report, + "finalized": True, + "tx_hex": tx_hex, + } diff --git a/helpers/scan.py b/helpers/scan.py new file mode 100644 index 0000000..9ef4bf3 --- /dev/null +++ b/helpers/scan.py @@ -0,0 +1,873 @@ +""" +scan.py — Silent Payments blockchain scanner for the silnt LNbits extension. +""" + +from __future__ import annotations +import asyncio, hashlib, struct +from dataclasses import dataclass +from typing import Optional +import coincurve, httpx +from coincurve import PublicKey +from loguru import logger +from ..crud import ( + db, + DEFAULT_CONFIG_NETWORK, + get_backend_config, + get_silnt_wallet, + get_silnt_wallets, + get_wallet_addresses, + insert_utxos_for_wallet, + update_balance, + ensure_labeled_address_row +) +from .dust_check import evaluate_dust_for_wallet +from .wallet import generate_labeled_sp_address, get_spend_pub_from_secret + +_scan_progress: dict[str, dict] = {} +_scan_stop: dict[str, bool] = {} +SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 +BIP352_CHANGE_LABEL_INDEX = 1 +BIP352_LABELED_ADDRESS_INDICES=[2,3] + +@dataclass +class Label: + pub_key: bytes + tweak: bytes + address: str = "" + m: int = 0 + + +@dataclass +class FoundOutput: + output: bytes + sec_key_tweak: bytes + label: Optional[Label] = None + + +@dataclass +class OwnedUTXO: + txid: bytes + vout: int + amount: int + priv_key_tweak: bytes + pub_key: bytes + utxo_state: str + timestamp: int = 0 + label: Optional[Label] = None + label_text: Optional[str] = None + + def to_db_row(self, wallet_id: str) -> dict: + return { + "txid": self.txid.hex(), + "vout": self.vout, + "amount": self.amount, + "priv_key_tweak": self.priv_key_tweak.hex(), + "pub_key": self.pub_key.hex(), + "utxo_state": self.utxo_state, + "timestamp": self.timestamp, + "wallet_id": wallet_id, + "label": self.label_text, + "label_index": self.label.m if self.label else None + } + + +def request_scan_stop(wallet_id: str): + _scan_stop[wallet_id] = True + + +def should_stop(wallet_id: str) -> bool: + return _scan_stop.get(wallet_id, False) + + +def clear_scan_stop(wallet_id: str): + _scan_stop.pop(wallet_id, None) + + +def _tagged_hash(tag: str, data: bytes) -> bytes: + h = hashlib.sha256(tag.encode()).digest() + return hashlib.sha256(h + h + data).digest() + + +def _ser_u32(k: int) -> bytes: + return struct.pack(">I", k) + + +def create_shared_secret( + public_component: bytes, scan_key: bytes, input_hash: Optional[bytes] = None +) -> bytes: + if input_hash is not None: + scalar = ( + int.from_bytes(scan_key, "big") * int.from_bytes(input_hash, "big") + ) % SECP256K1_N + effective_key = scalar.to_bytes(32, "big") + else: + effective_key = scan_key + return PublicKey(public_component).multiply(effective_key).format(compressed=True) + + +def create_output_pub_key_and_tweak( + shared_secret: bytes, spend_pub_key: bytes, k: int +) -> tuple[bytes, bytes]: + t_k = _tagged_hash("BIP0352/SharedSecret", shared_secret + _ser_u32(k)) + p_k = ( + PublicKey(spend_pub_key) + .combine([PublicKey.from_secret(t_k)]) + .format(compressed=True) + ) + return p_k[1:], t_k + + +def add_public_keys(pk1: bytes, pk2: bytes) -> bytes: + if isinstance(pk1, PublicKey): + pk1 = pk1.format(compressed=True) + if isinstance(pk2, PublicKey): + pk2 = pk2.format(compressed=True) + return PublicKey(pk1).combine([PublicKey(pk2)]).format(compressed=True) + + +def negate_public_key(pk: bytes) -> bytes: + return bytes([0x02 if pk[0] == 0x03 else 0x03]) + pk[1:] + + +def add_private_keys(sk1: bytes, sk2: bytes) -> bytes: + return ( + (int.from_bytes(sk1, "big") + int.from_bytes(sk2, "big")) % SECP256K1_N + ).to_bytes(32, "big") + + +def create_label(scan_key: bytes, m: int) -> Label: + tweak = _tagged_hash("BIP0352/Label", scan_key + _ser_u32(m)) + return Label( + pub_key=PublicKey.from_secret(tweak).format(compressed=True), tweak=tweak, m=m + ) + + +def create_labels(scan_key: bytes, indices: list[int]) -> list[Label]: + all_indices = sorted(set([0, BIP352_CHANGE_LABEL_INDEX] + BIP352_LABELED_ADDRESS_INDICES + list(indices))) + return [create_label(scan_key, m) for m in all_indices] + + +def match_labels( + tx_output_33: bytes, pk_33: bytes, labels: list # list[Label] +): # -> Optional[Label] + try: + diff = add_public_keys(tx_output_33, negate_public_key(pk_33)) + except Exception: + return None + for label in labels: + # X-only comparison (includes parity byte) — avoids matching a label + # that only shares an x-coordinate (its negation), which would produce a + # wrong tweak and an unspendable detected output. + if diff[1:] == label.pub_key[1:]: + return label + return None + + +def receiver_scan_transaction_with_shared_secret( + scan_key: bytes, + spend_pub_key: bytes, + labels: list[Label], + tx_outputs: list[bytes], + shared_secret: bytes, +) -> list[FoundOutput]: + found_outputs: list[FoundOutput] = [] + remaining = list(tx_outputs) + k = 0 + while True: + output_pub_key, tweak = create_output_pub_key_and_tweak( + shared_secret, spend_pub_key, k + ) + found = False + for i, tx_output in enumerate(remaining): + if output_pub_key == tx_output: + found_outputs.append(FoundOutput(output=tx_output, sec_key_tweak=tweak)) + remaining.pop(i) + found = True + k += 1 + break + if not labels: + continue + tx_out_33 = b"\x02" + tx_output + out_pk_33 = b"\x02" + output_pub_key + fl = match_labels(tx_out_33, out_pk_33, labels) + if fl: + found_outputs.append( + FoundOutput( + output=tx_output, + sec_key_tweak=add_private_keys(tweak, fl.tweak), + label=fl, + ) + ) + remaining.pop(i) + found = True + k += 1 + break + tx_out_neg = negate_public_key(tx_out_33) + fl = match_labels(tx_out_neg, out_pk_33, labels) + if fl: + found_outputs.append( + FoundOutput( + output=tx_out_neg[1:], + sec_key_tweak=add_private_keys(tweak, fl.tweak), + label=fl, + ) + ) + remaining.pop(i) + found = True + k += 1 + break + if not found: + break + return found_outputs + + +def receiver_scan_transaction( + scan_key, spend_pub_key, labels, tx_outputs, public_component, input_hash +): + return receiver_scan_transaction_with_shared_secret( + scan_key, + spend_pub_key, + labels, + tx_outputs, + create_shared_secret(public_component, scan_key, input_hash), + ) + + +def sync_block(tweaks, utxos, scan_key, spend_pub_key, labels): + tweak_script_map: dict[bytes, tuple[bytes, bytes]] = {} + raw = tweaks[0] + tweak = bytes.fromhex(raw) if isinstance(raw, str) else raw + ss = create_shared_secret(tweak, scan_key) + opk, _ = create_output_pub_key_and_tweak(ss, spend_pub_key, 0) + for raw in tweaks: + tweak = bytes.fromhex(raw) if isinstance(raw, str) else raw + ss = create_shared_secret(tweak, scan_key) + opk, _ = create_output_pub_key_and_tweak(ss, spend_pub_key, 0) + tweak_script_map[opk] = (tweak, opk) + b33 = b"\x02" + opk + for label in labels: + try: + lo = add_public_keys(b33, label.pub_key) + tweak_script_map[lo[1:]] = (tweak, lo[1:]) + except Exception as e: + logger.debug(f"label add m={label.m}: {e}") + try: + lon = add_public_keys(b33, negate_public_key(label.pub_key)) + tweak_script_map[lon[1:]] = (tweak, lon[1:]) + except Exception as e: + logger.debug(f"label neg m={label.m}: {e}") + if not tweak_script_map: + return [] + txid_groups: dict[bytes, list[dict]] = {} + helper_mapping: dict[bytes, bytes] = {} + for u in utxos: + tb = bytes.fromhex(u["txid"]) + xo = bytes.fromhex(u["pubkey"]) + txid_groups.setdefault(tb, []).append(u) + helper_mapping[xo] = tb + to_check: dict[bytes, list[dict]] = {} + for xo, (tw, _) in tweak_script_map.items(): + if xo in helper_mapping: + to_check[tw] = txid_groups[helper_mapping[xo]] + owned: list[OwnedUTXO] = [] + for tw, rel_utxos in to_check.items(): + found = receiver_scan_transaction( + scan_key, + spend_pub_key, + labels, + [bytes.fromhex(u["pubkey"]) for u in rel_utxos], + tw, + None, + ) + for fo in found: + for u in rel_utxos: + if fo.output == bytes.fromhex(u["pubkey"]): + owned.append( + OwnedUTXO( + txid=bytes.fromhex(u["txid"]), + vout=u["vout"], + amount=u["amount"], + priv_key_tweak=fo.sec_key_tweak, + pub_key=fo.output, + utxo_state="unspent", + timestamp=u.get("timestamp", 0), + label=fo.label, + ) + ) + break + return owned + + +def sync_block_from_compute_index(index, scan_key, spend_pub_key, labels): + owned: list[OwnedUTXO] = [] + for entry in index: + tweak_hex = entry.get("tweak", "") + txid = entry.get("txid", "") + outputs_hex = entry.get("outputs", []) + if not tweak_hex or not outputs_hex: + continue + try: + ss = create_shared_secret(bytes.fromhex(tweak_hex), scan_key) + shorts = set(outputs_hex) + k = 0 + while True: + opk, t_k = create_output_pub_key_and_tweak(ss, spend_pub_key, k) + matched = False + if opk.hex()[:16] in shorts: + owned.append( + OwnedUTXO( + txid=bytes.fromhex(txid), + vout=0, + amount=0, + priv_key_tweak=t_k, + pub_key=opk, + utxo_state="unspent", + ) + ) + matched = True + if labels: + b33 = b"\x02" + opk + for label in labels: + try: + lo = add_public_keys(b33, label.pub_key) + if lo[1:].hex()[:16] in shorts: + owned.append( + OwnedUTXO( + txid=bytes.fromhex(txid), + vout=0, + amount=0, + priv_key_tweak=add_private_keys( + t_k, label.tweak + ), + pub_key=lo[1:], + utxo_state="unspent", + label=label, + ) + ) + matched = True + except Exception: + pass + if not matched: + break + k += 1 + except Exception as e: + logger.warning(f"compute_index txid={txid}: {e}") + return owned + +async def get_outspend_status(base_mempool_url: str, txid: str, vout: int) -> dict | None: + """ + Exact-outpoint spent check via mempool. + Returns {"spent": bool} when known, or None on unknown/error (caller leaves + the UTXO in unconfirmed_spent and retries next scan). + """ + base = (base_mempool_url or "https://mempool.space").rstrip("/") + url = f"{base}/api/tx/{txid}/outspend/{vout}" + try: + async with httpx.AsyncClient(timeout=10.0, verify=False) as c: + r = await c.get(url) + if r.status_code == 404: + # outpoint unknown to explorer — can't confirm; treat as unknown + return None + if r.status_code != 200: + return None + data = r.json() + return {"spent": bool(data.get("spent", False))} + except Exception as e: + logger.warning(f"outspend check failed for {txid}:{vout}: {e}") + return None + +class BlindBitOracleClient: + def __init__(self, base_url: str): + self.base_url = base_url.rstrip("/") + + def _client(self): + return httpx.AsyncClient(timeout=30.0, verify=False) + + async def get_chain_tip(self) -> int: + async with self._client() as c: + return (await c.get(f"{self.base_url}/info")).json()["height"] + + async def get_tweaks(self, height: int) -> list[bytes]: + async with self._client() as c: + r = await c.get(f"{self.base_url}/tweaks/{height}") + r.raise_for_status() + d = r.json() + return [ + bytes.fromhex(t) for t in (d["index"] if isinstance(d, dict) else d) + ] + + async def get_utxos(self, height: int) -> list[dict]: + async with self._client() as c: + r = await c.get(f"{self.base_url}/utxos/{height}") + r.raise_for_status() + d = r.json() + return d["index"] if isinstance(d, dict) else d + + async def get_spent_outputs(self, height: int) -> Optional[dict]: + async with self._client() as c: + r = await c.get(f"{self.base_url}/spent-outputs/{height}") + return None if r.status_code == 404 else r.json() + + async def get_compute_index(self, height: int) -> Optional[dict]: + async with self._client() as c: + r = await c.get(f"{self.base_url}/compute-index/{height}") + if r.status_code == 404: + return None + d = r.json() + return d if isinstance(d, dict) and "index" in d else {"index": d} + + async def get_block_hash(self, height: int) -> Optional[dict]: + async with self._client() as c: + r = await c.get(f"{self.base_url}/blockhash/{height}") + return None if r.status_code == 404 else r.json() + + +async def scan_block( + height, client, scan_secret_bytes, spend_pub_bytes, spend_secret_bytes, labels, + network: str, +): + if labels: + tweaks = await client.get_tweaks(height) + if not tweaks: + return [] + utxos = await client.get_utxos(height) + if not utxos: + return [] + # Offload the synchronous EC matching to a worker thread so it doesn't + # block the event loop — keeps the API/UI responsive during a scan. + loop = asyncio.get_event_loop() + owned = await loop.run_in_executor( + None, sync_block, tweaks, utxos, scan_secret_bytes, spend_pub_bytes, labels + ) + for o in owned: + if not o.timestamp: + o.timestamp = await get_block_ts(o.txid.hex(), network) + return owned + compute_data = await client.get_compute_index(height) + if compute_data: + loop = asyncio.get_event_loop() + matches = await loop.run_in_executor( + None, sync_block_from_compute_index, + compute_data["index"], scan_secret_bytes, spend_pub_bytes, labels + ) + if matches: + full_utxos = await client.get_utxos(height) + lkp = {u["pubkey"]: u for u in full_utxos if "pubkey" in u} + for owned in matches: + ph = owned.pub_key.hex() + full = lkp.get(ph) or next( + (u for u in full_utxos if u.get("pubkey", "")[:16] == ph[:16]), None + ) + if full: + owned.vout = full.get("vout", owned.vout) + owned.amount = full.get("amount", owned.amount) + owned.timestamp = full.get("timestamp") or await get_block_ts( + full.get("txid", ""), network + ) + owned.pub_key = bytes.fromhex(full["pubkey"]) + return matches + tweaks = await client.get_tweaks(height) + if not tweaks: + return [] + utxos = await client.get_utxos(height) + if not utxos: + return [] + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, sync_block, tweaks, utxos, scan_secret_bytes, spend_pub_bytes, labels + ) + + +async def mark_spent_utxos_batch(heights, client, wallet_id, owned_utxos_lookup, network): + """ + Short-hash matches move UTXOs to 'unconfirmed_spent' (provisional), then each + is verified against the exact outpoint via mempool outspend before finalizing. + Replaces the previous version that finalized 'spent' directly on an 8-byte + short-hash match. + """ + if not owned_utxos_lookup: + return + + # Resolve the mempool base once for verification. + backend = await get_backend_config(network) + mempool_base = backend.mempool_url or "https://mempool.space" + + async def check_height(height: int): + try: + spent_data = await client.get_spent_outputs(height) + if not spent_data: + return + spent_set = set(spent_data.get("index", [])) + if not spent_set: + return + + rows = await db.fetchall( + """SELECT txid, vout, pub_key FROM silnt.utxos + WHERE wallet_id = :wallet_id + AND utxo_state IN ('unspent', 'unconfirmed_spent')""", + {"wallet_id": wallet_id}, + ) + for row in rows: + short_pub = row["pub_key"][:16] # 8 bytes = 16 hex chars + if short_pub not in spent_set: + continue + # PROVISIONAL: short-hash match → mark unconfirmed_spent, do NOT + # finalize. Only flip from 'unspent'; leave existing + # 'unconfirmed_spent' as-is (verification below handles both). + await db.execute( + """UPDATE silnt.utxos SET utxo_state = 'unconfirmed_spent' + WHERE txid = :txid AND vout = :vout + AND wallet_id = :wallet_id + AND utxo_state = 'unspent'""", + {"txid": row["txid"], "vout": row["vout"], "wallet_id": wallet_id}, + ) + # VERIFY the exact outpoint before finalizing. + status = await get_outspend_status(mempool_base, row["txid"], row["vout"]) + if status is None: + # Unknown — leave as unconfirmed_spent, retry next scan. + logger.info( + f"{row['txid']}:{row['vout']} short-hash matched at block " + f"{height}; outspend unknown — left unconfirmed_spent" + ) + continue + if status["spent"]: + await db.execute( + """UPDATE silnt.utxos SET utxo_state = 'spent' + WHERE txid = :txid AND vout = :vout + AND wallet_id = :wallet_id + AND utxo_state = 'unconfirmed_spent'""", + {"txid": row["txid"], "vout": row["vout"], "wallet_id": wallet_id}, + ) + logger.info( + f"Confirmed {row['txid']}:{row['vout']} spent (outspend) " + f"at block {height}" + ) + else: + # FALSE POSITIVE: 8-byte short-hash collision. Restore unspent. + await db.execute( + """UPDATE silnt.utxos SET utxo_state = 'unspent' + WHERE txid = :txid AND vout = :vout + AND wallet_id = :wallet_id + AND utxo_state = 'unconfirmed_spent'""", + {"txid": row["txid"], "vout": row["vout"], "wallet_id": wallet_id}, + ) + logger.warning( + f"Short-hash FALSE POSITIVE: {row['txid']}:{row['vout']} " + f"matched spent-index at block {height} but outspend says " + f"unspent — restored to unspent." + ) + except Exception as e: + logger.warning(f"mark_spent_utxos error at block {height}: {e}") + + # Verification adds an explorer call per matched UTXO. Matches are rare + # (only your own spends), so this stays cheap. Kept within the same + # asyncio.gather over heights as before. + await asyncio.gather(*[check_height(h) for h in heights]) + + +async def set_last_scan_height(wallet_id: str, height: int) -> None: + await db.execute( + "UPDATE silnt.wallets SET last_scan_height = :height WHERE id = :id", + {"height": height, "id": wallet_id}, + ) + + +def get_scan_progress(wallet_id: str) -> dict: + return _scan_progress.get( + wallet_id, {"active": False, "current": 0, "total": 0, "found": 0} + ) + + +def set_scan_progress(wallet_id, current, total, found, active=True): + _scan_progress[wallet_id] = { + "active": active, + "current": current, + "total": total, + "found": found, + } + + +async def scan_wallet( + wallet_id: str, + scan_secret_hex: str, # passed from client, never stored + spend_secret_hex: str, + from_height: Optional[int] = None, + to_height: Optional[int] = None, +) -> dict: + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise ValueError(f"Wallet {wallet_id} not found") + blindbit = await get_backend_config(wallet.network) + if not blindbit.blindbit_url: + raise ValueError("BlindBit Oracle URL not configured") + + scan_secret_bytes = bytes.fromhex(scan_secret_hex) + spend_secret_bytes = bytes.fromhex(spend_secret_hex) + spend_pub_bytes = coincurve.PublicKey.from_secret(spend_secret_bytes).format( + compressed=True + ) + + oracle = BlindBitOracleClient(base_url=blindbit.blindbit_url) + start = max(from_height if from_height is not None else wallet.last_height, 1) + end = to_height if to_height is not None else await oracle.get_chain_tip() + logger.info(f"Scanning wallet {wallet_id} blocks {start}–{end}") + + saved_addresses = await get_wallet_addresses(wallet_id) + labels = create_labels( + scan_secret_bytes, indices=[a.label_index for a in saved_addresses] + ) + addr_label_map: dict[int, str] = { + a.label_index: a.label + for a in saved_addresses + if getattr(a, "label", None) + } + total_found = 0 + blocks_scanned = 0 + last_scanned_height = start + total_blocks = end - start + 1 + stopped = False + clear_scan_stop(wallet_id) + set_scan_progress(wallet_id, 0, total_blocks, 0) + # Smaller batches keep the span of work between event-loop yields short, so + # navigation/API calls stay responsive during a scan. + BATCH_SIZE = 5 + + for batch_start in range(0, total_blocks, BATCH_SIZE): + if should_stop(wallet_id): + logger.info(f"Scan stopped at {last_scanned_height}") + stopped = True + await set_last_scan_height(wallet_id, last_scanned_height) + set_scan_progress( + wallet_id, blocks_scanned, total_blocks, total_found, active=False + ) + clear_scan_stop(wallet_id) + break + + batch = list( + range(start + batch_start, min(start + batch_start + BATCH_SIZE, end + 1)) + ) + + owned_rows = await db.fetchall( + "SELECT txid, vout FROM silnt.utxos WHERE wallet_id = :wallet_id AND utxo_state IN ('unspent', 'unconfirmed_spent')", + {"wallet_id": wallet_id}, + ) + owned_utxos_lookup = {f"{r['txid']}:{r['vout']}": r for r in owned_rows} + + batch_results = await asyncio.gather( + *[ + scan_block( + h, + oracle, + scan_secret_bytes, + spend_pub_bytes, + spend_secret_bytes, + labels, + wallet.network, + ) + for h in batch + ], + return_exceptions=True, + ) + + await mark_spent_utxos_batch(batch, oracle, wallet_id, owned_utxos_lookup, wallet.network) + + for h, result in zip(batch, batch_results): + if isinstance(result, Exception): + logger.error(f"Block {h} error: {result}") + continue + if result: + # Inherit address labels onto matching UTXOs (added earlier) + for owned in result: + if owned.label and owned.label.m in addr_label_map: + owned.label_text = addr_label_map[owned.label.m] + elif owned.label and owned.label.m >= 2: + try: + hrp = "sp" if (wallet.network == "mainnet") else "tsp" + spend_pub_hex = get_spend_pub_from_secret(spend_secret_hex) + labeled_addr = generate_labeled_sp_address( + scan_secret_hex=scan_secret_hex, + spend_pub_hex=spend_pub_hex, + m=owned.label.m, + hrp=hrp, + ) + await ensure_labeled_address_row( + wallet_id, labeled_addr, owned.label.m + ) + except Exception as e: + logger.warning(f"Could not restore labeled address m={owned.label.m}: {e}") + try: + await insert_utxos_for_wallet(wallet_id, result) + total_found += len(result) + logger.info(f"Block {h}: {len(result)} UTXOs") + except Exception as ins_err: + # Log full DB error server-side for the admin to investigate, + # but don't crash the whole scan — just skip this block's results + logger.error( + f"Block {h}: found {len(result)} UTXO(s) but DB insert failed: {ins_err}" + ) + continue + blocks_scanned += 1 + last_scanned_height = h + + set_scan_progress(wallet_id, blocks_scanned, total_blocks, total_found) + await set_last_scan_height(wallet_id, last_scanned_height) + + # Yield to the event loop between batches so other requests (wallet + # loads, navigation) get serviced promptly during a long scan. + await asyncio.sleep(0) + + await set_last_scan_height(wallet_id, last_scanned_height) + set_scan_progress( + wallet_id, blocks_scanned, total_blocks, total_found, active=False + ) + + try: + rec = await reconcile_unconfirmed_spent(wallet_id, wallet.network) + if rec["restored"] or rec["confirmed"]: + logger.info( + f"Wallet {wallet_id} reconcile: " + f"{rec['confirmed']} confirmed, {rec['restored']} restored, " + f"{rec['pending']} still pending" + ) + except Exception as e: + logger.warning(f"Reconcile failed for {wallet_id}: {e}") + + unspent = await db.fetchall( + "SELECT amount FROM silnt.utxos WHERE wallet_id = :wallet_id AND utxo_state = 'unspent'", + {"wallet_id": wallet_id}, + ) + balance = sum(r["amount"] for r in unspent) + await update_balance(wallet_id, balance) + try: + newly_flagged = await evaluate_dust_for_wallet(wallet_id) + if newly_flagged > 0: + logger.info(f"Wallet {wallet_id}: flagged {newly_flagged} new dust UTXO(s)") + except Exception as e: + logger.warning(f"Dust evaluation failed for {wallet_id}: {e}") + logger.info( + f"Scan done: {blocks_scanned} blocks, {total_found} UTXOs, balance={balance}" + ) + set_scan_progress(wallet_id, blocks_scanned, total_blocks, total_found, active=False) + return { + "utxos_found": total_found, + "blocks_scanned": blocks_scanned, + "final_height": last_scanned_height, + "balance": balance, + "stopped": stopped + } + + +async def scan_all_wallets(user: str, network: str = "mainnet") -> list[dict]: + results = [] + for wallet in await get_silnt_wallets(user, network): + try: + r = await scan_wallet(wallet.id) + r["wallet_id"] = wallet.id + results.append(r) + except Exception as e: + logger.error(f"Wallet {wallet.id} failed: {e}") + results.append({"wallet_id": wallet.id, "error": str(e)}) + return results + + +async def get_block_ts(txid: str, network: str = DEFAULT_CONFIG_NETWORK) -> int: + backend = await get_backend_config(network) + base = (backend.mempool_url or "https://mempool.space").rstrip("/") + try: + async with httpx.AsyncClient(timeout=10.0) as c: + r = await c.get(f"{base}/api/tx/{txid}") + if r.status_code == 200: + return int(r.json().get("status", {}).get("block_time") or 0) + except Exception: + pass + return 0 + +async def get_tx_status(base_mempool_url: str, txid: str) -> dict | None: + """ + Returns {"confirmed": bool} if the tx is known to the explorer, + or None if the tx is unknown (404 — dropped / never propagated). + """ + base = (base_mempool_url or "https://mempool.space").rstrip("/") + url = f"{base}/api/tx/{txid}" + try: + async with httpx.AsyncClient(timeout=10.0, verify=False) as c: + r = await c.get(url) + if r.status_code == 404: + return None # unknown → dropped + if r.status_code != 200: + # transient error — treat as "unknown status", do nothing this round + return {"unknown": True} + data = r.json() + return {"confirmed": bool(data.get("status", {}).get("confirmed", False))} + except Exception as e: + logger.warning(f"tx-status check failed for {txid}: {e}") + return {"unknown": True} # network hiccup — don't change state + + +async def reconcile_unconfirmed_spent(wallet_id: str, network: str = DEFAULT_CONFIG_NETWORK) -> dict: + """ + Walk the wallet's unconfirmed_spent UTXOs and reconcile each against the + explorer. Returns counts of {confirmed, restored, pending}. + """ + backend = await get_backend_config(network) + base = backend.mempool_url or "https://mempool.space" + + rows = await db.fetchall( + """SELECT txid, vout, spent_in_txid FROM silnt.utxos + WHERE wallet_id = :wid AND utxo_state = 'unconfirmed_spent'""", + {"wid": wallet_id}, + ) + if not rows: + return {"confirmed": 0, "restored": 0, "pending": 0} + + # Group outpoints by the spending txid so we query each tx once + by_txid: dict[str, list[tuple[str, int]]] = {} + for r in rows: + stx = r["spent_in_txid"] + if not stx: + # No spending txid recorded but state is unconfirmed_spent — anomalous. + # Safest is to leave it; a manual restore can handle it. + continue + by_txid.setdefault(stx, []).append((r["txid"], r["vout"])) + + confirmed = restored = pending = 0 + + for spending_txid, outpoints in by_txid.items(): + status = await get_tx_status(base, spending_txid) + + if status is None: + # Dropped / unknown to the explorer → the spend never happened. + # Restore these inputs to unspent so they're spendable again. + for (in_txid, in_vout) in outpoints: + await db.execute( + """UPDATE silnt.utxos + SET utxo_state = 'unspent', + spent_in_txid = NULL, + spent_at = NULL + WHERE wallet_id = :wid AND txid = :txid AND vout = :vout + AND utxo_state = 'unconfirmed_spent'""", + {"wid": wallet_id, "txid": in_txid, "vout": in_vout}, + ) + restored += 1 + logger.info( + f"Restored {len(outpoints)} UTXO(s) to unspent — spending tx " + f"{spending_txid} was dropped/unknown" + ) + + elif status.get("confirmed"): + # The spend confirmed → finalize as spent. + for (in_txid, in_vout) in outpoints: + await db.execute( + """UPDATE silnt.utxos SET utxo_state = 'spent' + WHERE wallet_id = :wid AND txid = :txid AND vout = :vout + AND utxo_state = 'unconfirmed_spent'""", + {"wid": wallet_id, "txid": in_txid, "vout": in_vout}, + ) + confirmed += 1 + logger.info(f"Finalized {len(outpoints)} UTXO(s) spent by confirmed tx {spending_txid}") + + else: + # Still pending in mempool, or transient error → leave unchanged. + pending += len(outpoints) + + return {"confirmed": confirmed, "restored": restored, "pending": pending} \ No newline at end of file diff --git a/helpers/scan_rate_limiter.py b/helpers/scan_rate_limiter.py new file mode 100644 index 0000000..cfb05d9 --- /dev/null +++ b/helpers/scan_rate_limiter.py @@ -0,0 +1,143 @@ +""" +Rate limiting for the scan endpoint. + +BIP-352 scanning is expensive — each block needs tweaks + UTXOs fetched from +BlindBit and EC math per output. Without limits, a single user (intentionally +or via bug) can hammer the oracle. + +Limits enforced: + 1. Per-wallet cooldown: same wallet can't be scanned more often than every N seconds + 2. Concurrent scans per user: only one active scan at a time per user + 3. Per-IP scan starts per hour + 4. Per-user blocks-scanned budget per hour +""" + +import time +from collections import defaultdict +from http import HTTPStatus +from typing import Optional + +from fastapi import HTTPException, Request +from loguru import logger + + +# ── Configurable limits (could live in BackendConfig if you want admin UI control) +WALLET_COOLDOWN_SECONDS = 60 # 1 minute between scans of same wallet +MAX_CONCURRENT_PER_USER = 1 # only one active scan per user +MAX_SCAN_STARTS_PER_IP_HR = 30 # 30 scan-starts/hour per IP +MAX_BLOCKS_PER_USER_HR = 500_000 # ~half a million blocks/hour per user + + +# ── State (in-memory — fine for single-process LNbits) ─────────────────────── +_last_scan_time: dict = {} # wallet_id -> ts +_active_scans: dict = defaultdict(set) # user_id -> {wallet_id, ...} +_ip_scan_log: dict = defaultdict(list) # ip -> [ts, ts, ...] +_user_blocks_log: dict = defaultdict(list) # user_id -> [(ts, count), ...] + + +def _prune_old(entries: list, age_seconds: int) -> list: + """Drop entries older than age_seconds. Entries are timestamps or (ts, *) tuples.""" + cutoff = time.time() - age_seconds + return [ + e for e in entries + if (e[0] if isinstance(e, tuple) else e) >= cutoff + ] + + +def check_scan_allowed( + user_id: str, + wallet_id: str, + ip: str, + estimated_blocks: int, +) -> None: + """ + Raise HTTPException(429) if any rate limit would be violated by this scan. + Call this BEFORE starting the scan. Increments counters as side effects. + """ + now = time.time() + + # ── 1. Per-wallet cooldown ──────────────────────────────────────────────── + last_ts = _last_scan_time.get(wallet_id, 0) + if now - last_ts < WALLET_COOLDOWN_SECONDS: + wait = int(WALLET_COOLDOWN_SECONDS - (now - last_ts)) + raise HTTPException( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + detail=f"Wallet was scanned recently. Try again in {wait} seconds.", + ) + + # ── 2. Concurrent scans per user ────────────────────────────────────────── + if len(_active_scans[user_id]) >= MAX_CONCURRENT_PER_USER: + raise HTTPException( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + detail="Another scan is already running on your account. Wait for it to finish.", + ) + + # ── 3. Per-IP scan starts per hour ──────────────────────────────────────── + _ip_scan_log[ip] = _prune_old(_ip_scan_log[ip], 3600) + if len(_ip_scan_log[ip]) >= MAX_SCAN_STARTS_PER_IP_HR: + raise HTTPException( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + detail="Too many scan attempts from this IP. Try again later.", + ) + + # ── 4. Per-user blocks budget per hour ──────────────────────────────────── + _user_blocks_log[user_id] = _prune_old(_user_blocks_log[user_id], 3600) + consumed = sum(count for _ts, count in _user_blocks_log[user_id]) + if consumed + estimated_blocks > MAX_BLOCKS_PER_USER_HR: + remaining = MAX_BLOCKS_PER_USER_HR - consumed + raise HTTPException( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + detail=( + f"Hourly scan budget exceeded ({MAX_BLOCKS_PER_USER_HR:,} blocks/hour). " + f"You have {remaining:,} blocks remaining this hour. " + f"Try a smaller range or wait." + ), + ) + + # All checks passed — record this scan + _last_scan_time[wallet_id] = now + _active_scans[user_id].add(wallet_id) + _ip_scan_log[ip].append(now) + _user_blocks_log[user_id].append((now, estimated_blocks)) + + logger.info( + f"Scan allowed: user={user_id[:8]} wallet={wallet_id[:8]} " + f"blocks={estimated_blocks} ip={ip}" + ) + + +def mark_scan_finished( + user_id: str, + wallet_id: str, + actual_blocks: Optional[int] = None, + estimated_blocks: Optional[int] = None, + reset_wallet_cooldown: bool = False, +) -> None: + """ + Release the concurrent-scan slot once a scan finishes (success/failure/stop). + + If actual_blocks and estimated_blocks are given, reconcile the per-user block + budget: at start we debited the ESTIMATE; here we refund the difference so the + user is only charged for blocks ACTUALLY scanned (oracle load done). + + If reset_wallet_cooldown is True (user explicitly stopped), clear the per-wallet + cooldown so they can retry immediately — the block budget is the real oracle-load + guard, and a self-initiated stop isn't the rapid-rescan case the cooldown targets. + """ + _active_scans[user_id].discard(wallet_id) + + # Reconcile the block budget: replace the most recent estimate-debit for this + # user with the actual count (refund the unscanned remainder). + if actual_blocks is not None and estimated_blocks is not None: + log = _user_blocks_log.get(user_id) + if log: + # Find the most recent entry matching the estimate we debited and + # correct it to the actual blocks scanned. + for i in range(len(log) - 1, -1, -1): + ts, count = log[i] + if count == estimated_blocks: + log[i] = (ts, max(0, int(actual_blocks))) + break + + if reset_wallet_cooldown: + _last_scan_time.pop(wallet_id, None) \ No newline at end of file diff --git a/helpers/transactions.py b/helpers/transactions.py new file mode 100644 index 0000000..8e643b8 --- /dev/null +++ b/helpers/transactions.py @@ -0,0 +1,165 @@ +import time +import httpx +from loguru import logger +from ..crud import ( + get_backend_config, + get_wallet_receives, + get_wallet_sends, + get_utxos_for_txid, + get_utxos_spent_in_tx, + get_owned_pubkeys, +) + + +# Per-process in-memory cache. Keyed by (mempool_base, txid). +# Acceptable to lose on restart — this is non-critical metadata. +_TX_CACHE: dict[tuple[str, str], tuple[float, dict]] = {} +_TX_CACHE_TTL = 3600 # 1 hour + + +async def _fetch_tx_from_mempool(txid: str, mempool_base: str) -> dict | None: + """Fetch a tx's full mempool.space data, cached for 1hr.""" + key = (mempool_base, txid) + now = time.time() + cached = _TX_CACHE.get(key) + if cached and now - cached[0] < _TX_CACHE_TTL: + return cached[1] + + base = mempool_base.rstrip("/") + try: + async with httpx.AsyncClient(timeout=10.0) as c: + r = await c.get(f"{base}/api/tx/{txid}") + if r.status_code != 200: + return None + data = r.json() + if bool(data.get("status", {}).get("confirmed")): + _TX_CACHE[key] = (now, data) + return data + except Exception as e: + logger.warning(f"Tx fetch failed for {txid}: {e}") + return None + + +async def list_wallet_transactions( + wallet_id: str, + limit: int = 50, + offset: int = 0, +) -> list[dict]: + """ + Combined chronological transaction list from local DB (no mempool calls). + + Each row: + { kind: 'send'|'receive', + txid, timestamp, amount_sats, # negative = net outflow, positive = inflow + input_sum, output_sum, + input_count, output_count, + labels: [str] } + """ + receives = {r["txid"]: r for r in await get_wallet_receives(wallet_id)} + sends = {s["txid"]: s for s in await get_wallet_sends(wallet_id)} + + txids = set(receives) | set(sends) + rows = [] + for txid in txids: + rcv = receives.get(txid) + snd = sends.get(txid) + + if snd: + # Spent inputs → a send. If the wallet also owns an output (change or + # a consolidation back to self), subtract it so amount = net outflow. + input_sum = snd["input_sum"] + output_sum = rcv["output_sum"] if rcv else 0 + net_out = input_sum - output_sum # sent amount + fee + kind = "send" + timestamp = snd["spent_at"] or (rcv["timestamp"] if rcv else 0) + amount_sats = -net_out # negative = outflow + labels = rcv["labels"] if rcv else [] + input_count = snd["input_count"] + output_count = rcv["output_count"] if rcv else 0 + confirmed = snd.get("pending_inputs", 0) == 0 + else: + # No inputs spent → pure receive. + input_sum = 0 + output_sum = rcv["output_sum"] + kind = "receive" + timestamp = rcv["timestamp"] + amount_sats = rcv["output_sum"] # positive = inflow + labels = rcv["labels"] + input_count = 0 + output_count = rcv["output_count"] + confirmed = True + + rows.append({ + "kind": kind, + "txid": txid, + "timestamp": timestamp, + "amount_sats": amount_sats, + "input_sum": input_sum, + "output_sum": output_sum, + "input_count": input_count, + "output_count": output_count, + "labels": labels, + }) + + rows.sort(key=lambda r: r["timestamp"], reverse=True) + return rows[offset:offset + limit] + + +async def get_wallet_transaction_detail( + wallet_id: str, + txid: str, + network: str, +) -> dict: + """ + Enriched view of one transaction. Pulls our own UTXOs from DB, then queries + mempool.space for the full tx (fee, recipient, confirmation status). + """ + backend = await get_backend_config(network) + mempool_base = backend.mempool_url or "https://mempool.space" + + own_outputs = await get_utxos_for_txid(wallet_id, txid) + spent_inputs = await get_utxos_spent_in_tx(wallet_id, txid) + owned_pubkeys = await get_owned_pubkeys(wallet_id) + tx = await _fetch_tx_from_mempool(txid, mempool_base) + + detail = { + "txid": txid, + "own_outputs": own_outputs, + "spent_inputs": spent_inputs, + "fee_sats": None, + "recipients": [], # outputs NOT owned by us + "confirmed": None, + "block_height": None, + "block_time": None, + "explorer_url": f"{mempool_base.rstrip('/')}/tx/{txid}", + } + + if tx is None: + return detail + + # Fee + detail["fee_sats"] = tx.get("fee") + + # Confirmation + status = tx.get("status", {}) + detail["confirmed"] = bool(status.get("confirmed")) + detail["block_height"] = status.get("block_height") + detail["block_time"] = status.get("block_time") + + # Recipients = outputs whose x-only key is NOT one of ours + for vout_data in tx.get("vout", []): + spk_asm = vout_data.get("scriptpubkey_asm") or "" + # Taproot: "OP_PUSHNUM_1 OP_PUSHBYTES_32 " + xonly = None + parts = spk_asm.split() + if len(parts) >= 3 and parts[0] == "OP_PUSHNUM_1": + xonly = parts[-1] + if xonly and xonly in owned_pubkeys: + continue + detail["recipients"].append({ + "address": vout_data.get("scriptpubkey_address"), + "amount": int(vout_data.get("value") or 0), + "type": vout_data.get("scriptpubkey_type"), + }) + + return detail \ No newline at end of file diff --git a/helpers/user.py b/helpers/user.py new file mode 100644 index 0000000..4bb0bd8 --- /dev/null +++ b/helpers/user.py @@ -0,0 +1,54 @@ +from lnbits.settings import settings as lnbits_settings +from lnbits.core.models import WalletTypeInfo +from http import HTTPStatus +from fastapi import HTTPException +from ..crud import get_backend_config, DEFAULT_CONFIG_NETWORK + +def require_admin(key_info: WalletTypeInfo) -> None: + """Raise 403 if the caller is not an LNbits admin.""" + if not is_lnbits_admin(key_info.wallet.user): + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Admin privileges required.", + ) + +def is_lnbits_admin(user_id: str) -> bool: + """ + Check if the user_id has admin rights in LNbits. + Admin users are configured via LNBITS_ADMIN_USERS env var (comma-separated) + plus the LNBITS_SUPER_USER. + """ + # super_user is a single string or None + super_user = getattr(lnbits_settings, "super_user", "") or "" + if user_id and user_id == super_user: + return True + + # admin_users is either a list[str] or a comma-separated string depending + # on LNbits version — handle both + admin_users = getattr(lnbits_settings, "lnbits_admin_users", []) or [] + if isinstance(admin_users, str): + admin_users = [u.strip() for u in admin_users.split(",") if u.strip()] + return user_id in admin_users + +async def validate_born_height(last_height, network: str = DEFAULT_CONFIG_NETWORK) -> int | None: + """ + Validate a requested born-at height against the system minimum. + Returns the validated int height, or None if not provided. Raises 400 if below min. + """ + if last_height is None or last_height == "": + return None + try: + h = int(last_height) + except (TypeError, ValueError): + raise HTTPException(HTTPStatus.BAD_REQUEST, "Born-at height must be a number.") + if h < 0: + raise HTTPException(HTTPStatus.BAD_REQUEST, "Born-at height must be positive.") + + backend = await get_backend_config(network) + min_h = int(getattr(backend, "min_scan_height", 0) or 0) + if min_h > 0 and h < min_h: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + f"Born-at height ({h}) is below the system minimum of {min_h}.", + ) + return h \ No newline at end of file diff --git a/helpers/wallet.py b/helpers/wallet.py new file mode 100644 index 0000000..7b272a8 --- /dev/null +++ b/helpers/wallet.py @@ -0,0 +1,608 @@ +import base64 +import coincurve +import hashlib +import httpx +import io +import math +import struct +from typing import Optional +from base64 import b64encode +from embit import bip32, bip39, ec, finalizer, script +from embit.networks import NETWORKS +from binascii import hexlify +from .curve import bech32_encode, Encoding +from .curve import pubkey_point_gen_from_int, int_from_bytes, Point +from loguru import logger +from lnbits.utils.crypto import AESCipher +from cryptography.fernet import Fernet +from embit.transaction import ( + Transaction, + TransactionInput, + TransactionOutput, + SIGHASH, + Witness, +) +from embit.psbt import PSBT, InputScope +from embit.script import Script +from embit.networks import NETWORKS +from .curve import ( + decode, + convertbits, + pubkey_point_gen_from_int, + int_from_bytes, + point_add, + point_mul, + serP, + ser256, + has_even_y, + G, + p as CURVE_P, +) +from embit.ec import SchnorrSig +from mnemonic import Mnemonic +SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 + +# ── Phase 1: per-input tweaked signing keys + scripts (orig steps 1–2) ──────── +def _prepare_inputs(spend_key, utxos: list[dict]): + """ + For each UTXO, derive the full signing key (priv_key_tweak + spend_key, with + BIP-340 odd-Y negation) and its taproot script. Returns parallel lists. + """ + n = SECP256K1_N + input_keys = [] + input_scripts = [] + for utxo in utxos: + priv_key_tweak_hex = utxo.get("priv_key_tweak") or "" + if not priv_key_tweak_hex: + raise ValueError(f"Missing priv_key_tweak for utxo {utxo['txid']}") + + pub_key_hex = utxo.get("pub_key") or "" + if not pub_key_hex: + raise ValueError(f"Missing pub_key for utxo {utxo['txid']}") + + priv_tweak_int = int_from_bytes(bytes.fromhex(priv_key_tweak_hex)) + spend_key_int = int_from_bytes(spend_key.secret) + full_secret_int = (priv_tweak_int + spend_key_int) % n + + full_pub_point = pubkey_point_gen_from_int(full_secret_int) + if not has_even_y(full_pub_point): + full_secret_int = n - full_secret_int + full_pub_point = pubkey_point_gen_from_int(full_secret_int) + + signing_key = ec.PrivateKey(full_secret_int.to_bytes(32, "big")) + actual_x_only = bytes.fromhex(pub_key_hex) + actual_script = Script(bytes([0x51, 0x20]) + actual_x_only) + + input_keys.append(signing_key) + input_scripts.append((actual_script, actual_x_only)) + + return input_keys, input_scripts + + +# ── Phase 2: recipient scriptPubKey (orig step 3) ───────────────────────────── +def _derive_recipient_script(recipient: str, spend_key, utxos: list[dict]) -> Script: + if recipient.startswith("sp1") or recipient.startswith("tsp1"): + return Script(derive_sp_scriptpubkey(recipient, spend_key.secret, utxos)) + try: + return script.address_to_scriptpubkey(recipient) + except Exception as e: + raise ValueError(f"Invalid recipient address: {str(e)}") + + +# ── Phase 3: amounts, fee, change (orig step 4) ─────────────────────────────── +def _compute_amounts(utxos: list[dict], amount: int, fee_rate: float): + """ + Returns (total_input, fee, change_amount, estimated_vsize). + Dust change (<546) is absorbed into the fee, leaving change_amount = 0. + """ + total_input = sum(u["amount"] for u in utxos) + estimated_vsize = int(10 + (57.5 * len(utxos)) + (31 * 2)) + fee = max(1, math.ceil(estimated_vsize * fee_rate)) + change_amount = total_input - amount - fee + + if change_amount < 0: + raise ValueError( + f"Insufficient funds. Need {amount + fee} sats " + f"(including {fee} sats fee), have {total_input} sats." + ) + + if 0 < change_amount < 546: + logger.debug(f"Change {change_amount} sats below dust — adding to fee") + fee += change_amount + change_amount = 0 + + return total_input, fee, change_amount, estimated_vsize + + +# ── Phase 4: BIP-352 m=1 change scriptPubKey (orig step 5) ──────────────────── +def _derive_change_script(change_amount, scan_secret_hex, spend_key, utxos, network): + """Returns the change Script, or None when change is dust/zero.""" + if change_amount < 546: + return None + spend_pub_hex = coincurve.PublicKey.from_secret( + spend_key.secret + ).format(compressed=True).hex() + hrp = "sp" if network.lower() == "mainnet" else "tsp" + change_sp_address = generate_labeled_sp_address( + scan_secret_hex=scan_secret_hex, + spend_pub_hex=spend_pub_hex, + m=1, # BIP-352 change label + hrp=hrp, + ) + return Script(derive_sp_scriptpubkey(change_sp_address, spend_key.secret, utxos)) + + +# ── Phase 5: assemble + verify outputs (orig steps 7 + 7b) ──────────────────── +def _assemble_outputs(amount, recipient_script, change_amount, change_script, recipient): + tx_outputs = [TransactionOutput(amount, recipient_script)] + if change_script is not None: + tx_outputs.append(TransactionOutput(change_amount, change_script)) + + tx_outputs.sort( + key=lambda x: ( + x.value, + x.script_pubkey.data.hex() + if hasattr(x.script_pubkey, "data") + else bytes(x.script_pubkey).hex(), + ) + ) + + if recipient.startswith("sp1") or recipient.startswith("tsp1"): + if not verify_sp_output(recipient_script, tx_outputs): + raise ValueError( + "Derived SP recipient output not found in transaction outputs. " + "Funds would be unrecoverable. Aborting." + ) + if change_script is not None: + if not verify_sp_output(change_script, tx_outputs): + raise ValueError( + "Derived SP change output not found in transaction outputs. " + "Change would be unrecoverable. Aborting." + ) + + return tx_outputs + + +# ── Phase 6: build PSBT, sign, finalize, serialize (orig steps 6 + 8 + 9 + 10) ─ +def _build_sign_finalize(utxos, input_keys, input_scripts, tx_outputs): + """ + BIP69-sort inputs, construct + sign (SIGHASH_DEFAULT) + finalize the PSBT, + return the serialized tx hex. + """ + # step 6 — inputs (BIP69 sorted), keep keys/scripts/amounts aligned + tx_inputs_with_keys = [ + ( + TransactionInput(bytes.fromhex(u["txid"]), int(u.get("vout", 0))), + input_keys[i], + input_scripts[i], + u["amount"], + ) + for i, u in enumerate(utxos) + ] + tx_inputs_with_keys.sort(key=lambda x: (x[0].txid.hex(), x[0].vout)) + + tx_inputs = [t[0] for t in tx_inputs_with_keys] + input_keys_sorted = [t[1] for t in tx_inputs_with_keys] + input_scripts_sorted = [t[2] for t in tx_inputs_with_keys] + input_amounts_sorted = [t[3] for t in tx_inputs_with_keys] + + # step 8 — construct PSBT + witness_utxos + tx = Transaction(vin=tx_inputs, vout=tx_outputs) + psbt = PSBT(tx) + for i in range(len(tx_inputs)): + psbt.inputs[i].witness_utxo = TransactionOutput( + input_amounts_sorted[i], input_scripts_sorted[i][0] + ) + + # step 9 — sign each input (SIGHASH_DEFAULT = 0, commits to whole tx) + utxo_amounts = [inp.witness_utxo.value for inp in psbt.inputs] + utxo_scripts = [inp.witness_utxo.script_pubkey for inp in psbt.inputs] + for i in range(len(psbt.inputs)): + h = taproot_sighash(tx, i, utxo_scripts, utxo_amounts, sighash_type=0) + priv_bytes = input_keys_sorted[i].secret + cc_key = coincurve.PrivateKey(priv_bytes) + sig_bytes = cc_key.sign_schnorr(h).rjust(64, b"\x00") + psbt.inputs[i].taproot_key_sig = SchnorrSig.parse(sig_bytes) + + # step 10 — finalize + extract + for inp in psbt.inputs: + if inp.taproot_key_sig is not None: + inp.final_scriptwitness = Witness([inp.taproot_key_sig.serialize()]) + inp.final_scriptsig = Script(b"") + for i, inp in enumerate(psbt.inputs): + if inp.final_scriptwitness: + tx.vin[i].witness = inp.final_scriptwitness + + return tx.serialize().hex() + +def _validate_or_generate_mnemonic( + plain_mnemonic: Optional[str], +) -> tuple[str, bool]: + """ + Returns (mnemonic_plain, was_generated). + - If plain_mnemonic is provided: validate 12 words + BIP-39 checksum. + - If absent: generate a fresh 12-word seed. + """ + mn = Mnemonic("english") + + if plain_mnemonic: + words = plain_mnemonic.strip().lower().split() + if len(words) != 12: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Mnemonic must be exactly 12 words (got {len(words)}).", + ) + normalized = " ".join(words) + if not mn.check(normalized): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Invalid mnemonic — the checksum (last word) is incorrect. " + "Double-check your recovery phrase.", + ) + return normalized, False + + # Generate a new 12-word mnemonic (128 bits entropy) + return mn.generate(strength=128), True + +def get_seed(mnemonic, passphrase: str = "") -> bytes: + """ + Re‑creates the BIP‑39 seed from the hard‑coded mnemonic. + Returns the 64‑byte seed (as `bytes`). + """ + seed = bip39.mnemonic_to_seed(mnemonic, password=passphrase) + return seed + + +def generate_hardened_keys(seed, network: str = "mainnet") -> dict: + # BIP352: coin_type 0 = mainnet, 1 = testnet/signet + coin_type = 0 if network == "mainnet" else 1 + xprv_version = ( + NETWORKS["main"]["xprv"] if network == "mainnet" else NETWORKS["test"]["xprv"] + ) + + root = bip32.HDKey.from_seed(seed, version=xprv_version) + + scan_path = f"m/352h/{coin_type}h/0h/1h/0" + spend_path = f"m/352h/{coin_type}h/0h/0h/0" + + scan_private_key = root.derive(scan_path).key.secret + spend_private_key = root.derive(spend_path).key.secret + scank = root.derive(scan_path).key.secret + spendk = root.derive(spend_path) + + return { + "scan_priv_key": scan_private_key, + "spend_priv_key": spend_private_key, + "scank": hexlify(scank).decode(), + "spendk": spendk.get_public_key(), + } + + +def encode_silent_payment_address( + B_scan: Point, B_m: Point, hrp: str = "tsp", version: int = 0 +) -> str: + if B_scan is None or B_m is None: + raise ValueError("ERROR: Invalid data.") + ret = bech32_encode( + hrp, [version] + convertbits(serP(B_scan) + serP(B_m), 8, 5), Encoding.BECH32M + ) + if decode(hrp, ret) == (None, None): + raise ValueError("ERROR: Invalid data.") + return ret + + +async def generate_silent_wallet_address(mnemonic, passphrase="", network: str = "mainnet") -> tuple: + seed = get_seed(mnemonic, passphrase=passphrase) + key_material = generate_hardened_keys(seed, network) + + B_scan = pubkey_point_gen_from_int(int_from_bytes(key_material["scan_priv_key"])) + B_spend = pubkey_point_gen_from_int(int_from_bytes(key_material["spend_priv_key"])) + + # mainnet → 'sp', signet/testnet → 'tsp' + hrp = "sp" if network == "mainnet" else "tsp" + sp = encode_silent_payment_address(B_scan, B_spend, hrp, 0) + + spend_priv_hex = hexlify(key_material["spend_priv_key"]).decode() + scan_key_hex = key_material["scank"] + + return (str(sp), scan_key_hex, spend_priv_hex) + + +def parse_sp_address(sp_address: str) -> tuple: + """Extract B_scan and B_spend as compressed pubkey bytes from a Silent Payment address.""" + hrp = "tsp" if sp_address.startswith("tsp") else "sp" + version, decoded = decode(hrp, sp_address) + if decoded is None: + raise ValueError(f"Invalid Silent Payment address: {sp_address}") + b_scan_bytes = bytes(decoded[:33]) + b_spend_bytes = bytes(decoded[33:66]) + return b_scan_bytes, b_spend_bytes + + +def compressed_pubkey_to_point(compressed: bytes) -> tuple: + """Parse a compressed SEC1 pubkey bytes into a curve Point.""" + prefix = compressed[0] + x = int_from_bytes(compressed[1:33]) + y_sq = (pow(x, 3, CURVE_P) + 7) % CURVE_P + y = pow(y_sq, (CURVE_P + 1) // 4, CURVE_P) + if (y % 2 == 0) != (prefix == 0x02): + y = CURVE_P - y + return (x, y) + + +def derive_sp_scriptpubkey( + sp_address: str, + spend_secret: bytes, + utxos: list[dict], +) -> bytes: + b_scan_bytes, b_spend_bytes = parse_sp_address(sp_address) + n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 + + # Step 1: sum input private keys, negating taproot keys with odd Y + a_sum = 0 + A_points = [] + for u in utxos: + priv = ( + int.from_bytes(bytes.fromhex(u["priv_key_tweak"]), "big") + + int.from_bytes(spend_secret, "big") + ) % n + pub_point = pubkey_point_gen_from_int(priv) + if pub_point[1] % 2 == 1: # odd Y — negate (taproot requirement) + priv = n - priv + pub_point = pubkey_point_gen_from_int(priv) + a_sum = (a_sum + priv) % n + A_points.append(pub_point) + + # Step 2: A_sum = sum of all input pubkeys + A_sum_point = A_points[0] + for pt in A_points[1:]: + A_sum_point = point_add(A_sum_point, pt) + A_sum_bytes = bytes([0x02 + (A_sum_point[1] % 2)]) + ser256(A_sum_point[0]) + + # Step 3: smallest outpoint = min(reversed_txid || vout_LE) + outpoints = [ + bytes.fromhex(u["txid"])[::-1] + int(u.get("vout", 0)).to_bytes(4, "little") + for u in utxos + ] + outpointL = min(outpoints) + + # Step 4: input_hash = TaggedHash("BIP0352/Inputs", outpointL || A_sum) + input_hash_bytes = tagged_hash("BIP0352/Inputs", outpointL + A_sum_bytes) + input_hash = int.from_bytes(input_hash_bytes, "big") + + # Step 5: shared_secret = (a_sum * input_hash) * B_scan + a_tweaked = (a_sum * input_hash) % n + B_scan = compressed_pubkey_to_point(b_scan_bytes) + ecdh_point = point_mul(B_scan, a_tweaked) + + # Step 6: t_k = TaggedHash("BIP0352/SharedSecret", serP(ecdh) || ser32(0)) + ecdh_compressed = bytes([0x02 + (ecdh_point[1] % 2)]) + ser256(ecdh_point[0]) + t_k_bytes = tagged_hash( + "BIP0352/SharedSecret", ecdh_compressed + (0).to_bytes(4, "big") + ) + t_k = int.from_bytes(t_k_bytes, "big") + + # Step 7: P = B_spend + t_k*G + B_spend = compressed_pubkey_to_point(b_spend_bytes) + tG = pubkey_point_gen_from_int(t_k) + P = point_add(B_spend, tG) + + return bytes([0x51, 0x20]) + ser256(P[0]) + + +def verify_sp_output( + recipient_script: Script, + tx_outputs: list[TransactionOutput], +) -> bool: + """ + Verify the derived SP scriptpubkey appears in the transaction outputs. + Call this after building tx_outputs but before signing. + """ + expected = bytes(recipient_script.data) + for out in tx_outputs: + out_script = ( + out.script_pubkey.data + if hasattr(out.script_pubkey, "data") + else bytes(out.script_pubkey) + ) + if out_script == expected: + return True + return False + +# ── Public entry point — same signature + return as before ──────────────────── +def build_transaction( + spend_key_hex: str, + scan_secret_hex: str, + recipient: str, + amount: int, + fee_rate: float, + utxos: list[dict], + network: str = "Mainnet", +) -> dict: + """ + Build and sign a Bitcoin transaction. + Supports Silent Payment addresses (sp1/tsp1) and standard on-chain addresses. + Change goes to the wallet's own m=1 labeled SP address (BIP-352 reserved + change index). Returns dict with tx_hex, fee, amount, change. + """ + spend_key = ec.PrivateKey(bytes.fromhex(spend_key_hex)) + + input_keys, input_scripts = _prepare_inputs(spend_key, utxos) + recipient_script = _derive_recipient_script(recipient, spend_key, utxos) + total_input, fee, change_amount, estimated_vsize = _compute_amounts( + utxos, amount, fee_rate + ) + change_script = _derive_change_script( + change_amount, scan_secret_hex, spend_key, utxos, network + ) + tx_outputs = _assemble_outputs( + amount, recipient_script, change_amount, change_script, recipient + ) + tx_hex = _build_sign_finalize(utxos, input_keys, input_scripts, tx_outputs) + + return { + "tx_hex": tx_hex, + "fee": fee, + "amount": amount, + "change": change_amount, + "total_input": total_input, + "recipient": recipient, + "fee_rate_used": fee_rate, + "vsize": estimated_vsize, + } + + +def decrypt_mnemonic( + m: str | None = None, k: str | None = None, urlsafe: bool = False +) -> str | None: + """ + Decrypt message with the secret key + + Args: + m: Message to decrypt + k: Key used to decrypt + urlsafe: Whether the message uses URL-safe base64 encoding + """ + if not m: + return None + return AESCipher(key=k).decrypt(m, urlsafe=urlsafe) + + +def sha256(data: bytes) -> bytes: + return hashlib.sha256(data).digest() + + +def tagged_hash(tag: str, data: bytes) -> bytes: + tag_bytes = tag.encode() + tag_hash = sha256(tag_bytes) + return sha256(tag_hash + tag_hash + data) + + +def taproot_sighash( + tx: Transaction, + input_index: int, + utxo_scripts: list, + utxo_amounts: list, + sighash_type: int = 0, +) -> bytes: + """ + Compute BIP341 taproot key-path sighash manually. + """ + + # sha_prevouts + prevouts = b"" + for vin in tx.vin: + prevouts += vin.txid[::-1] + vin.vout.to_bytes(4, "little") + sha_prevouts = sha256(prevouts) + # sha_amounts + amounts = b"" + for amt in utxo_amounts: + amounts += amt.to_bytes(8, "little") + sha_amounts = sha256(amounts) + # sha_scriptpubkeys + scripts = b"" + for s in utxo_scripts: + script_bytes = s.data if hasattr(s, "data") else bytes(s) + scripts += len(script_bytes).to_bytes(1, "little") + script_bytes + sha_scriptpubkeys = sha256(scripts) + # sha_sequences + sequences = b"" + for vin in tx.vin: + sequences += vin.sequence.to_bytes(4, "little") + sha_sequences = sha256(sequences) + # sha_outputs + outputs = b"" + for vout in tx.vout: + script_bytes = ( + vout.script_pubkey.data + if hasattr(vout.script_pubkey, "data") + else bytes(vout.script_pubkey) + ) + outputs += vout.value.to_bytes(8, "little") + outputs += len(script_bytes).to_bytes(1, "little") + script_bytes + sha_outputs = sha256(outputs) + # spend_type = 0 (key path, no annex) + spend_type = (0).to_bytes(1, "little") + + # input data + vin = tx.vin[input_index] + input_data = ( + vin.txid + + vin.vout.to_bytes(4, "little") + + utxo_amounts[input_index].to_bytes(8, "little") + + len(utxo_scripts[input_index].data).to_bytes(1, "little") + + utxo_scripts[input_index].data + + vin.sequence.to_bytes(4, "little") + ) + + # Full sighash preimage + preimage = ( + bytes([0x00]) # epoch + + sighash_type.to_bytes(1, "little") # hash_type + + tx.version.to_bytes(4, "little") # nVersion + + tx.locktime.to_bytes(4, "little") # nLockTime + + sha_prevouts + + sha_amounts + + sha_scriptpubkeys + + sha_sequences + + sha_outputs + + spend_type + + input_index.to_bytes(4, "little") # input_index + ) + return tagged_hash("TapSighash", preimage) + + +def generate_labeled_sp_address( + scan_secret_hex: str, spend_pub_hex: str, m: int, hrp: str = "sp" +) -> str: + """ + Derive a BIP-352 LABELED Silent Payment address. + + B_m = B_spend + TaggedHash("BIP0352/Label", b_scan || ser32(m)) * G + + Reserved indices (per BIP-352 spec): + - m=0 → default address (NO tweak applied; computed elsewhere from + B_spend directly — do NOT call this function with m=0) + - m=1 → change label (reserved for self-send change outputs) + - m≥2 → user-defined labels + + Args: + scan_secret_hex: receiver's scan private key (hex) + spend_pub_hex: receiver's spend public key (33-byte compressed, hex) + m: label index (must be ≥ 1) + hrp: "sp" for mainnet, "tsp" for testnet/signet + """ + if not isinstance(m, int): + raise TypeError(f"label_index (m) must be an integer, got {type(m).__name__}") + if m < 1: + raise ValueError( + f"label_index must be ≥ 1 (m=0 is the default address — use a " + f"separate derivation; got m={m})" + ) + scan_secret_bytes = bytes.fromhex(scan_secret_hex) + spend_pub_bytes = bytes.fromhex(spend_pub_hex) + + # BIP352: TaggedHash("BIP0352/Label", scan_secret || ser32(m)) — big-endian + tag_hash = hashlib.sha256("BIP0352/Label".encode()).digest() + label_hash = hashlib.sha256( + tag_hash + tag_hash + scan_secret_bytes + struct.pack(">I", m) + ).digest() + + # B_m = B_spend + label_hash * G + B_m = coincurve.PublicKey.combine_keys( + [ + coincurve.PublicKey(spend_pub_bytes), + coincurve.PublicKey.from_secret(label_hash), + ] + ).format(compressed=True) + + # B_scan pubkey + B_scan = coincurve.PublicKey.from_secret(scan_secret_bytes).format(compressed=True) + + return bech32_encode(hrp, [0] + convertbits(B_scan + B_m, 8, 5), Encoding.BECH32M) + + +def get_spend_pub_from_secret(spend_secret_hex: str) -> str: + """Derive compressed spend public key from spend private key hex.""" + pub = coincurve.PublicKey.from_secret(bytes.fromhex(spend_secret_hex)) + return pub.format(compressed=True).hex() diff --git a/index.html b/index.html new file mode 100644 index 0000000..b3514b8 --- /dev/null +++ b/index.html @@ -0,0 +1,742 @@ + + + + + +Thrilla — Silent Payments & BitMail, self-custodial + + + + + + + + + + + + + + + + +
+ + + +
+ + +
+
+ Thrilla mascot — a celebrating Bitcoin coin +
Self-custodial · Silent Payments · PayJoin · Friends & family
+

A friends-and-family wallet
with an address you can actually say out loud.

+

Thrilla is a self-custodial friends-and-family wallet built on LNbits. Inside your circle you transact with collaborative PayJoin; to the outside world you receive with maximum-privacy Silent Payments and share a human-readable BitMail name. Powered by a BlindBit Oracle and a Fulcrum electrum server — your keys stay on your device and never reach the server.

+ +
SIGNET Test it with worthless coins first — no real funds required. Active development is ongoing, so the platform may briefly go offline from time to time.
+ +
+
thrilla — receive
+
+
+
Silent Payment address
+
sp1qq2f8…d9k3 · reusable forever
+
One static code. Every payment lands on a fresh, unlinkable output only you can detect.
+
+
+
BitMail address
+
alice@mydomain.net
+
The same payment code, wrapped in an email-style name your friends can remember.
+
+
+
+
+ +
+
+
Who it's for
+

A friends-and-family wallet — trust inside, privacy outside

+

Thrilla is designed for a known circle — friends, family, a small community — sharing one LNbits instance. Within that circle there's an expected level of trust, so members can transact collaboratively via PayJoin: two people co-sign a single transaction that improves privacy and consolidates coins, without a middleman.

+

Toward the outside world, you present your Silent Payment address — reusable, unlinkable, and safe to publish — so the public ledger never becomes a map of who pays you. Friendly trust on the inside, maximum privacy on the outside.

+

Your keys are your responsibility. Thrilla is self-custodial: every member generates and holds their own keys on their own device. No one — not other members, not the server operator — can move your Silent Payment funds for you, and no one can recover them if you lose your backup.

+
+
+ +
+
+
What it does
+
Reusable addresses without the privacy tax
+

Traditional Bitcoin forces a trade-off: reuse one address and leak your whole history, or generate endless new ones and drown in bookkeeping. Silent Payments end that compromise — and collaborative PayJoin lets your circle transact together with better privacy.

+
+
+
+ 🔇 +

Silent Payments

+

Publish a single static address. Senders derive a unique on-chain output for each payment using shared-secret cryptography, so nothing on the blockchain links your payments together — yet only you can find and spend them.

+ Reusable & unlinkable +
+
+ 🤝 +

Collaborative PayJoin

+

Pay another member of your instance with a transaction you both contribute inputs to. This breaks the common "all inputs share one owner" assumption that chain-analysis relies on, and can consolidate UTXOs at the same time. Designed for the trusted circle — both sides co-sign one transaction, no third party in between.

+ Within your circle +
+
+ ✉️ +

BitMail

+

Long payment codes are hard to share. BitMail maps a friendly name@domain to your Silent Payment address over DNS, so you can hand someone alice@mydomain.net instead of a 116-character string.

+ DNS-backed +
+
+ +

Lightning wallet On mainnet

+

Each account will include a built-in Lightning wallet for instant, low-fee spending — the natural pairing with Silent Payments for private receiving. Arriving when Thrilla launches on mainnet; the current Signet release focuses on Silent Payments and PayJoin.

+ Coming at mainnet launch +
+
+ 🔁 +

On-chain ↔ Lightning swaps On mainnet

+

Move Silent Payment funds into Lightning without an exchange, via non-custodial submarine swaps through Boltz. This will become available once Thrilla launches on mainnet alongside the Lightning wallet.

+ Coming at mainnet launch +
+
+ 👥 +

Multi-user by design

+

One deployment serves a circle of independent users, each with their own wallet, addresses, and device trust. Thrilla rides on LNbits' account model, so onboarding a new person is just another login.

+ One instance, many wallets +
+
+
+ +
+
+
Before you start
+
Create an account to secure your device
+

Thrilla ties each wallet to securely authenticated devices. Creating an account is what makes that possible — and it only takes a moment.

+
+ +
+
📧
+
+

Account creation is required

+

To use Thrilla you must create an account. This is what lets the app securely authenticate your device: each new device is confirmed via an email link before it can access a wallet, so a password alone is never enough to reach your funds from an unknown machine.

+

The confirmation email comes from . If you don't see it within a minute, check your spam or junk folder and mark it as "not spam" so future messages arrive in your inbox.

+
+
+
+ +
+
+
Get started
+
Fund your wallet with free Signet coins
+

Signet coins have no monetary value — they exist purely for testing. Grab some from a faucet and send your first Silent Payment in minutes.

+
+ +
+
+
1
+
+

Create an account & confirm your device

+

Sign up, then click the confirmation link emailed from (check spam if needed). Generate a Silent Payment wallet and copy your sp1q… address.

+
+
+
+
2
+
+

Claim coins from the Signet faucet

+

Paste your address into the faucet and request test coins. They'll arrive on-chain shortly — scan your wallet to detect them.

+ + Open the Signet faucet + +
+
+
+
3
+
+

Send, receive, PayJoin & explore

+

Try a payment, claim a BitMail name, do a collaborative PayJoin with another member, and watch unlinkable outputs land — all risk-free with worthless coins.

+
+
+
+
SIGNET The faucet at silentpayments.dev dispenses test coins only — never send real Bitcoin to a Signet address.
+
+ +
+
+

Try Thrilla on Signet

+

Create an account, generate a Silent Payment address, claim a BitMail name, and try a collaborative PayJoin with a friend — all with worthless test coins. No risk, no real funds.

+ Open signet.thrilla.me + + +
SIGNET Test network · Lightning & Boltz swaps arrive at mainnet launch · coins have no monetary value
+
+
+
+ + +
+
+
+
Why it matters
+
Privacy that holds up on a public ledger
+

Every Bitcoin transaction is permanent and public. The way you receive funds decides whether that ledger becomes a map of your financial life — or stays just noise to everyone but you.

+
+ +
+
+
⚠ Reusing one normal address
+
    +
  • Every payment to you is visibly clustered under the same address forever.
  • +
  • Anyone you've paid, or who's paid you, can see your total balance and history.
  • +
  • Posting an address publicly ties your real-world identity to all of it.
  • +
  • Chain-analysis firms link it to past and future activity automatically.
  • +
+
+
+
✓ One Silent Payment address
+
    +
  • Each payment lands on a fresh, unique output with no on-chain link to the others.
  • +
  • Observers see unrelated outputs — your balance and history stay private.
  • +
  • You can publish the same address anywhere without creating a public trail.
  • +
  • Only you, with your scan key, can even tell which outputs are yours.
  • +
+
+
+ +
+
+

No address reuse, ever

+

Share one static code for years. Each sender derives a different on-chain destination, so reuse of the public address never becomes reuse on the chain.

+
+
+

Unlinkable payments

+

Two payments to the same Silent Payment address produce outputs that look entirely unrelated to any third party — there's no common key or address joining them.

+
+
+

Collaborative PayJoin inside the circle

+

When members pay each other, both contribute inputs to one transaction. That defeats the "single owner of all inputs" heuristic chain-analysis leans on — privacy that comes from cooperation, not just cryptography.

+
+
+

Receiver-side privacy

+

You don't have to interact with the sender or hand out a new address each time. No address-request dance, no leaking which invoice belongs to whom.

+
+
+

Safe to publish

+

Put your address — or your BitMail name — on a profile, an invoice, or a donation page. Public exposure no longer means a public spending history.

+
+
+

Resistant to chain analysis

+

Clustering heuristics rely on address reuse and linkable, single-owner inputs. Silent Payments and PayJoin remove both signals, breaking the usual surveillance assumptions.

+
+
+ +
+
+ What Silent Payments & PayJoin don't do + Straight talk +
+
    +
  • Silent Payments protect the receiving side. The coins you spend still carry their own prior history, so good coin selection and labeling still matter.
  • +
  • They aren't a mixer. Amounts and the timing of transactions remain visible on-chain, even if the links between your received outputs aren't.
  • +
  • PayJoin assumes a trusted counterparty in your circle — it's a friends-and-family feature, and the other member sees their side of the transaction.
  • +
  • Network-level privacy is separate. Who broadcasts a transaction and from what IP isn't covered here — pair with Tor or your own node for that layer.
  • +
  • Your keys are your responsibility. Self-custody means there's no password reset for your funds — lose your backup and the coins are gone; no one can recover them for you.
  • +
  • BitMail resolves over DNS, so the resolver and the domain operator can see which name was looked up. The payment itself stays private; the lookup is a normal DNS query.
  • +
  • Privacy depends on how you use it. Linking an output to your identity elsewhere (an exchange, a public post) can still de-anonymize that specific payment.
  • +
+
+
+
+ + +
+
+
+
How it works
+
Layers doing one job
+

Thrilla is the interface. The cryptography, scanning, and collaborative transactions are handled by open components underneath it.

+
+
+
+
Layer 01 · Interface
+

Thrilla — the wallet you see

+

A fast web app that builds and signs Silent Payment and PayJoin transactions locally, manages your BitMail address, and shows balances and activity. All signing happens in your browser; the server only ever sees what it strictly needs to.

+
+
+
Layer 02 · Backend
+

LNbits — accounts & orchestration

+

Thrilla runs as an LNbits extension. LNbits provides the multi-user account system, authentication, and device confirmation, letting a single self-hosted instance serve a whole circle securely and independently. (The built-in Lightning wallet activates at mainnet launch.)

+
+
+
Layer 03 · Indexing
+

BlindBit & Fulcrum — indexing the chain

+

Finding your Silent Payment outputs means scanning the chain with your scan key. A BlindBit Oracle precomputes the tweak and UTXO indexes so your wallet can detect incoming payments quickly, and a Fulcrum electrum server supplies fast address and transaction lookups — all without your spend key ever leaving your device.

+
+
+
Layer 04 · Swaps On mainnet
+

Boltz — on-chain ↔ Lightning

+

Once Thrilla launches on mainnet, swaps will talk directly to a Boltz submarine-swap backend: you fund a lockup address from your SP wallet, Boltz pays your Lightning invoice, and the swap stays non-custodial — a failed swap is refundable on-chain with a key only your wallet holds.

+
+
+
+
+
+
+
Security model
+
The server never holds your keys
+

Self-custody isn't a slogan here — it's enforced by where the secrets physically live. Thrilla is built so that a full server compromise still can't move your Silent Payment coins. Those keys are yours to hold and yours to back up.

+
+
+
+
🔑
+
+

Seeds are never stored server-side

+

Your mnemonic and the keys derived from it are generated and held client-side, encrypted in your own browser. The server derives keys transiently during setup and discards them — there is no seed column in the database to leak. Backing up your seed is your responsibility.

+
+
+
+
📱
+
+

Account + device confirmation

+

Using Thrilla requires creating an account. New devices must then be explicitly confirmed via an email link (sent from — check spam) before they can access a wallet, so a stolen password alone can't reach your funds from an unknown machine.

+
+
+
+
✍️
+
+

Local signing

+

Transactions — including collaborative PayJoins — are constructed and signed in your browser using keys that never travel over the wire. The backend only broadcasts the finished, signed transaction — it can't author one on your behalf.

+
+
+
+
🤝
+
+

Collaborative, not custodial

+

A PayJoin between members is co-signed by both parties on a single transaction. Each side signs locally with their own keys; neither the other member nor the server can spend on your behalf.

+
+
+
+
🏠
+
+

Self-hostable, end to end

+

Run your own LNbits, BlindBit Oracle, and Fulcrum server (and, at mainnet, a Boltz backend) and the entire stack is yours — no third party sits between you and the chain, and no analytics company profiles your spending.

+
+
+
+
🔍
+
+

Verifiable, open components

+

Thrilla, LNbits, BlindBit, and Fulcrum are open and inspectable. The cryptography follows the open Silent Payments standard rather than a proprietary scheme you'd have to take on trust.

+
+
+
+
+
+
+
+
Built on
+
Standing on solid, open foundations
+
+
+
+
LNbits accounts + API
+
Multi-user, self-hosted Bitcoin & Lightning backend. Thrilla ships as an LNbits extension, inheriting its battle-tested auth, account model, and device confirmation.
+
+
+
BlindBit SP Oracle
+
A Silent Payments indexer that precomputes tweak and UTXO data, so wallets can scan for incoming payments fast — without exposing spend keys.
+
+
+
Boltz swaps · on mainnet
+
A non-custodial submarine-swap service. At mainnet launch, Thrilla will talk to a Boltz backend directly to move funds between on-chain Silent Payments and Lightning, trust-minimized.
+
+
+
Fulcrum electrum server
+
A fast electrum server providing address and transaction lookups, so your wallet can verify balances and build transactions without trusting a third-party explorer.
+
+
+
+
+ +
+
+
+
Thrilla · self-custodial Silent Payments, PayJoin & BitMail
+
Thrilla is experimental software currently on the Signet test network. Because it's under active testing, the platform may go down for short periods without notice. It is a friends-and-family wallet: self-custody means you are solely responsible for safeguarding your own keys and backups. Lightning and Boltz swaps arrive at mainnet launch. Nothing here is financial advice.
+ +
+
+ Home + Privacy + How + Signet +
+
+
+ + + + + diff --git a/manifest.json b/manifest.json index e7c1f9e..7ef572e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { "repos": [ { - "id": "example", + "id": "siLNt", "organisation": "lnbits", - "repository": "example" + "repository": "siLNt" } ] } diff --git a/migrations.py b/migrations.py index 9d37d97..f56f634 100644 --- a/migrations.py +++ b/migrations.py @@ -1,12 +1,520 @@ -# migrations.py is for building your database - -# async def m001_initial(db): -# await db.execute( -# f""" -# CREATE TABLE example.example ( -# id TEXT PRIMARY KEY, -# wallet TEXT NOT NULL, -# time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} -# ); -# """ -# ) +async def m001_initial(db): + """ + Initial wallet table. + """ + await db.execute( + f""" + CREATE TABLE silnt.wallets ( + id TEXT NOT NULL PRIMARY KEY, + "user" TEXT, + network TEXT DEFAULT 'mainnet', + title TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT {db.timestamp_now}, + updated_at TIMESTAMPTZ NOT NULL DEFAULT {db.timestamp_now}, + sp_address TEXT NOT NULL, + hr_address TEXT NOT NULL, + last_height INTEGER, + last_scan_height INTEGER NOT NULL DEFAULT 0, + balance {db.big_int} + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE silnt.utxos ( + txid TEXT NOT NULL PRIMARY KEY, + vout SMALLINT NOT NULL, + amount {db.big_int} NOT NULL, + priv_key_tweak TEXT NOT NULL, + pub_key TEXT NOT NULL, + utxo_state TEXT NOT NULL, + timestamp INTEGER NOT NULL DEFAULT 0, + wallet_id TEXT NOT NULL, + label TEXT DEFAULT NULL, + frozen BOOLEAN DEFAULT FALSE, + freeze_reason TEXT DEFAULT NULL, + suspected_dust BOOLEAN DEFAULT FALSE, + spent_in_txid TEXT DEFAULT NULL, + label_index INTEGER + ); + """ + ) + await db.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_silnt_utxos_vout_wallet_id ON silnt.utxos (txid, vout, wallet_id); + """ + ) + + +async def m002_subaccounts(db): + await db.execute(""" + CREATE TABLE IF NOT EXISTS silnt.wallet_addresses ( + sp_address TEXT PRIMARY KEY, + id TEXT NOT NULL, + wallet_id TEXT NOT NULL, + label TEXT DEFAULT NULL, + label_index INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0 + ) + """) + + +async def m003_add_spent_in_txid(db): + await db.execute( + "CREATE INDEX IF NOT EXISTS utxos_spent_in_txid ON silnt.utxos(spent_in_txid)" + ) + +async def m004_add_spent_at(db): + await db.execute( + "ALTER TABLE silnt.utxos ADD COLUMN IF NOT EXISTS spent_at INTEGER DEFAULT NULL" + ) + await db.execute( + "CREATE INDEX IF NOT EXISTS utxos_spent_at ON silnt.utxos(spent_at)" + ) + await db.execute( + "CREATE INDEX IF NOT EXISTS utxos_wallet_timestamp ON silnt.utxos(wallet_id, timestamp)" + ) + +async def m005_trusted_devices(db): + await db.execute( + """ + CREATE TABLE IF NOT EXISTS silnt.trusted_devices ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + device_id TEXT NOT NULL, + user_agent TEXT, + ip TEXT, + label TEXT, + confirmed_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + UNIQUE(user_id, device_id) + ) + """ + ) + await db.execute( + "CREATE INDEX IF NOT EXISTS trusted_devices_user ON silnt.trusted_devices(user_id)" + ) + +async def m006_user_prefs(db): + await db.execute( + """ + CREATE TABLE IF NOT EXISTS silnt.user_prefs ( + user_id TEXT PRIMARY KEY, + dust_threshold_sats INTEGER DEFAULT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + +async def m007_bip353_requests(db): + await db.execute( + """ + CREATE TABLE IF NOT EXISTS silnt.bip353_requests ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + wallet_id TEXT NOT NULL, + sp_address TEXT NOT NULL, + requested_username TEXT NOT NULL, + final_username TEXT, + message TEXT, + status TEXT NOT NULL DEFAULT 'pending', + reject_reason TEXT, + created_at INTEGER NOT NULL, + processed_at INTEGER, + processed_by TEXT + ) + """ + ) + await db.execute( + "CREATE INDEX IF NOT EXISTS bip353_requests_user ON silnt.bip353_requests(user_id, status)" + ) + await db.execute( + "CREATE INDEX IF NOT EXISTS bip353_requests_status ON silnt.bip353_requests(status, created_at)" + ) + +async def m008_boltz_swaps(db): + await db.execute( + f""" + CREATE TABLE silnt.boltz_swaps ( + id TEXT PRIMARY KEY, -- Boltz swap id + wallet_id TEXT NOT NULL, -- LNbits wallet (LN side) + silnt_wallet_id TEXT, -- SP wallet that funded it + status TEXT NOT NULL, -- created/funded/failed/refunded/completed + timeout_block_height INTEGER, -- refund nLockTime floor + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + json_data TEXT NOT NULL -- the rest (see SwapRecord) + ); + """ + ) + # index for finding swaps that may need refunding + await db.execute( + "CREATE INDEX idx_boltz_swaps_status ON silnt.boltz_swaps (status);" + ) + +async def m009_fix_utxos_primary_key(db): + """ + The original PK was on txid alone, making a txid globally unique across ALL + wallets. That's wrong: two different wallets can receive outputs in the same + transaction, and one tx can pay multiple vouts to one wallet. The txid-only PK + fired before the ON CONFLICT (txid,vout,wallet_id) upsert could absorb the + re-insert, causing a duplicate-key crash when a 2nd wallet saw the same txid. + Drop it and use the full outpoint-per-wallet as the PK (the unique index + idx_silnt_utxos_vout_wallet_id already enforces this combination). + """ + await db.execute("ALTER TABLE silnt.utxos DROP CONSTRAINT IF EXISTS utxos_pkey") + await db.execute( + "ALTER TABLE silnt.utxos " + "ADD CONSTRAINT utxos_pkey PRIMARY KEY (txid, vout, wallet_id)" + ) + +async def m010_bitmail_per_address(db): + """ + Per-address BitMail (BIP-353): each SP address — the wallet's base address + OR a labeled address — may have at most one BitMail, assigned once. + + 1. wallet_addresses.hr_address: the BitMail bound to a labeled address. + (The wallet's BASE address continues to use wallets.hr_address.) + 2. bip353_requests.address_id: which labeled address the request targets. + NULL = the wallet's base SP address (back-compat: all existing rows are + base-address requests, so NULL is the correct default for them). + """ + await db.execute( + "ALTER TABLE silnt.wallet_addresses " + "ADD COLUMN IF NOT EXISTS hr_address TEXT DEFAULT NULL" + ) + await db.execute( + "ALTER TABLE silnt.bip353_requests " + "ADD COLUMN IF NOT EXISTS address_id TEXT DEFAULT NULL" + ) + # Helps the per-address "already has an approved BitMail?" lifetime check. + await db.execute( + "CREATE INDEX IF NOT EXISTS bip353_requests_addr " + "ON silnt.bip353_requests(wallet_id, address_id, status)" + ) + +async def m011_payjoin_requests(db): + """ + PayJoin (BIP-84, watch-only, external Sparrow signing) coordination. + + Two users of this instance PayJoin each other. siLNt holds ONLY xpubs + PSBTs + — never seeds/keys. This table carries one PayJoin attempt through its state + machine and stores the in-progress PSBT (base64). + + State machine: + PROPOSED sender proposed; siLNt built the sender-only draft PSBT + CONTRIBUTED receiver accepted; siLNt added receiver input + bumped payment + output; receiver signed their input. Final unsigned PSBT exists. + FINALIZING sender signed the final PSBT; siLNt combining + finalizing + BROADCAST network tx broadcast; confirmation tracked by send-watch + CANCELLED declined / cancelled / expired (terminal) + """ + await db.execute( + f""" + CREATE TABLE silnt.payjoin_requests ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'PROPOSED', + + -- parties (on-instance, addressed by username per decision) + sender_user_id TEXT NOT NULL, + sender_username TEXT NOT NULL, + receiver_user_id TEXT, + receiver_username TEXT NOT NULL, + + -- which imported BIP-84 wallets are involved (refs payjoin_descriptors) + sender_descriptor_id TEXT NOT NULL, + receiver_descriptor_id TEXT, + + -- economics (sender pays fee per decision) + amount_sats {db.big_int} NOT NULL, + fee_rate REAL NOT NULL, + payment_address TEXT NOT NULL, -- receiver's payment address + receiver_input_sats {db.big_int}, -- R, once contributed + fee_sats {db.big_int}, -- computed at contribute time + + -- the in-progress artifact (base64). One column, overwritten as the + -- PSBT advances: draft -> final-unsigned -> partially-signed -> final + psbt TEXT, + -- the two parties' signed copies are merged by siLNt; we keep the + -- latest combined PSBT in `psbt` and the broadcast tx hex here: + unsigned_psbt TEXT, + -- receiver's signed copy (their partial_sig) stored at /contribute; + -- sender's signed copy arrives at /finalize and is combined with it. + receiver_signed_psbt TEXT, + tx_hex TEXT, + txid TEXT, + + -- selected inputs (JSON): sender inputs at propose, receiver input at + -- contribute. Outpoints + (chain,index) for derivation/validation. + sender_inputs TEXT, -- JSON array + receiver_input TEXT, -- JSON object + + reject_reason TEXT, + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + -- expiry stored as Unix seconds (BIGINT) rather than TIMESTAMP: the + -- codebase only ever uses db.timestamp_now for defaults and never + -- writes computed timestamps, so an int avoids datetime-serialization + -- ambiguity across Postgres/SQLite. + expires_at {db.big_int} + ); + """ + ) + # Lookups: receiver's incoming queue, sender's outgoing, expiry sweep. + await db.execute( + "CREATE INDEX idx_payjoin_receiver ON silnt.payjoin_requests (receiver_user_id, status);" + ) + await db.execute( + "CREATE INDEX idx_payjoin_sender ON silnt.payjoin_requests (sender_user_id, status);" + ) + await db.execute( + "CREATE INDEX idx_payjoin_status ON silnt.payjoin_requests (status, expires_at);" + ) + + +# Companion table: a user's imported watch-only BIP-84 wallet, imported as an +# OUTPUT DESCRIPTOR (single copy-paste from Sparrow). embit parses it, so siLNt +# extracts fingerprint + path + xpub from the descriptor itself — no separate +# fingerprint field for the user to find, and the key-origin used in PSBTs +# matches exactly what the wallet declared (avoids Sparrow-recognition issues). +async def m012_payjoin_descriptors(db): + """ + Imported watch-only BIP-84 accounts for PayJoin, as output descriptors. + User pastes e.g. wpkh([bc7a6fe7/84h/1h/0h]tpub.../<0;1>/*) + siLNt stores the raw descriptor plus the fields embit parses out of it. + siLNt stores ONLY public data (descriptor/xpub/fingerprint). No keys/seeds. + """ + await db.execute( + f""" + CREATE TABLE silnt.payjoin_descriptors ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + label TEXT, + descriptor TEXT NOT NULL, -- raw output descriptor (as pasted) + -- parsed from the descriptor by embit at import (cached for queries): + xpub TEXT NOT NULL, -- account xpub (tpub/xpub) + master_fp TEXT NOT NULL, -- 8 hex chars, from [origin] + account_path TEXT NOT NULL, -- e.g. "84h/1h/0h" + script_type TEXT NOT NULL DEFAULT 'wpkh', -- validated == wpkh + network TEXT NOT NULL, -- mainnet/signet/regtest + last_sync_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + + await db.execute( + "CREATE INDEX idx_payjoin_descriptors_user ON silnt.payjoin_descriptors (user_id);" + ) + +async def m013_payjoin_invoice_fields(db): + """ + Invoice model (payee-initiated, directed PayJoin): add a memo and a second + order-independent signature slot to payjoin_requests. + receiver_* = payee A (set at invoice creation, incl. A's contributed input) + sender_* = payer B (B's inputs set when B pays) + Runtime statuses: 'OPEN' (A posted invoice) -> 'CLAIMED' (B paid, PSBT built, + awaiting both signatures) -> 'BROADCAST' / 'CANCELLED'. status is TEXT, so no + enum to alter. Fulcrum config needs NO migration (BackendConfig is stored as + JSON in blindbit_config.json_data). + """ + await db.execute("ALTER TABLE silnt.payjoin_requests ADD COLUMN memo TEXT;") + await db.execute("ALTER TABLE silnt.payjoin_requests ADD COLUMN sender_signed_psbt TEXT;") + + +async def m014_payjoin_contacts(db): + """ + Consent-based connections between two users on this instance, so a payee can + keep a private, curated list of payers (and vice-versa) WITHOUT exposing the + global user base. One row per connection: + status PENDING -> requester asked target; awaiting target's approval + status ACCEPTED -> mutual connection; each may invoice/pay the other and + sees the other in their invoice payer-picker + Either party can delete the row (sever the connection). No enumeration: a + request is only created by exact known username (resolve-payer check). + """ + await db.execute( + f""" + CREATE TABLE silnt.payjoin_contacts ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'PENDING', + requester_user_id TEXT NOT NULL, + target_user_id TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + "CREATE INDEX idx_payjoin_contacts_req ON silnt.payjoin_contacts (requester_user_id, status);" + ) + await db.execute( + "CREATE INDEX idx_payjoin_contacts_tgt ON silnt.payjoin_contacts (target_user_id, status);" + ) + + +async def m015_payjoin_contact_labels(db): + """ + Per-side private labels for connections. Each user can label a connection + independently; the label is visible only to the labeler (not shared with the + counterparty). Keyed by (contact_id, labeler_user_id). Separate table because + the connection row itself is shared by both parties. + """ + await db.execute( + f""" + CREATE TABLE silnt.payjoin_contact_labels ( + contact_id TEXT NOT NULL, + labeler_user_id TEXT NOT NULL, + label TEXT NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + PRIMARY KEY (contact_id, labeler_user_id) + ); + """ + ) + + +async def m016_payjoin_descriptor_encryption(db): + """ + Privacy hardening: encrypt PayJoin descriptors/xpubs AT REST. + + An account xpub lets anyone holding it derive all of a wallet's addresses and + reconstruct its full balance/history (watch-only). The PayJoin flow needs the + descriptor server-side to coordinate, so we now store descriptor + xpub + encrypted (AESCipher keyed by the LNbits instance auth_secret) instead of in + plaintext. This protects a leaked DB dump/backup; it does not protect against + a full host compromise (which would also expose the key). + + Adds xpub_sha256: a non-reversible SHA256 tag used for duplicate-import + detection, since the encrypted xpub can't be matched by equality. Backfills + the tag for existing rows and encrypts any existing plaintext descriptor/xpub + in place. + """ + # 1) Add the dedup-hash column (nullable; backfilled below). + await db.execute("ALTER TABLE silnt.payjoin_descriptors ADD COLUMN xpub_sha256 TEXT;") + + # 2) Index the dedup tag for fast existence checks. + await db.execute( + "CREATE INDEX idx_payjoin_desc_xpubhash ON silnt.payjoin_descriptors (user_id, xpub_sha256);" + ) + + +async def m017_sp_contacts(db): + """ + Per-user private address book for SP sends. Users explicitly save recipients + (a raw SP address OR a BitMail name) with a label, to reuse on the Send + screen. Private to the saving user (scoped by user_id). The recipient value + is encrypted at rest (same as PayJoin descriptors) since it's a recipient + identity; the label is the user's own and kept plaintext. + """ + await db.execute( + f""" + CREATE TABLE silnt.sp_contacts ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + label TEXT NOT NULL, + kind TEXT NOT NULL, -- 'bitmail' | 'sp' + value TEXT NOT NULL, -- encrypted recipient (name or sp address) + value_sha256 TEXT, -- non-reversible dedup tag + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + last_used_at TIMESTAMP + ); + """ + ) + await db.execute( + "CREATE INDEX idx_sp_contacts_user ON silnt.sp_contacts (user_id, value_sha256);" + ) + +async def m019_drop_stray_utxos_vout_unique(db): + await db.execute("DROP INDEX IF EXISTS silnt.idx_silnt_utxos_vout") + await db.execute("ALTER TABLE silnt.utxos DROP CONSTRAINT IF EXISTS idx_silnt_utxos_vout") + +async def m020_admin_alerts(db): + """ + Admin-visible alerts surfaced in the Admin console (e.g. BitMail tampering + detected on a send: the DNS TXT for a siLNt-issued BitMail resolved to an SP + address that does NOT match the one siLNt recorded — a possible hijack). + """ + await db.execute( + f""" + CREATE TABLE IF NOT EXISTS silnt.admin_alerts ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'warning', + title TEXT NOT NULL, + detail TEXT NOT NULL DEFAULT '', + meta TEXT, + acknowledged BOOLEAN NOT NULL DEFAULT FALSE, + created_at INTEGER NOT NULL + ); + """ + ) + await db.execute( + "CREATE INDEX IF NOT EXISTS idx_admin_alerts_open " + "ON silnt.admin_alerts (acknowledged, created_at)" + ) + +async def m021_login_alerts(db): + """ + Dedup store for new-device sign-in alert emails. When an untrusted device + accesses an account, we email the user once per (user, device signature) + within a cooldown window — this table records the last time we alerted for a + given signature so refreshes / VPN flaps / cookie drops don't spam the user. + """ + await db.execute( + """ + CREATE TABLE IF NOT EXISTS silnt.login_alerts ( + user_id TEXT NOT NULL, + sig TEXT NOT NULL, -- hash of UA + IP (coarse device id) + last_alert_at INTEGER NOT NULL, + PRIMARY KEY (user_id, sig) + ); + """ + ) + +async def m022_device_codes(db): + """Short numeric codes for new-device confirmation (replaces email-link flow).""" + await db.execute( + """ + CREATE TABLE IF NOT EXISTS silnt.device_codes ( + user_id TEXT NOT NULL, + code_hash TEXT NOT NULL, + device_id TEXT NOT NULL, + user_agent TEXT, + ip TEXT, + expires_at INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id) + ); + """ + ) + +async def m023_backend_config_per_network(db): + # Rename blindbit_config -> backend_config, keyed per-network. The legacy + # singleton row (id='blindbit') is copied to its network id. Old table is + # left in place for now (dropped in a later cleanup migration after Stage 2). + await db.execute( + """ + CREATE TABLE IF NOT EXISTS silnt.backend_config ( + id TEXT PRIMARY KEY, + json_data TEXT NOT NULL DEFAULT '{}' + ); + """ + ) + + +async def m024_sp_contacts_network(db): + """Scope the SP address book per network. Without this, a user with wallets + on more than one network (e.g. a mainnet build reusing a signet test account) + sees every network's contacts. Contacts predate this column were created in + the signet-only phase, so backfill them to signet; new rows carry their + creating build's network. The dedup index gains `network` so the same + recipient can be saved on more than one network. + """ + await db.execute( + "ALTER TABLE silnt.sp_contacts ADD COLUMN network TEXT NOT NULL DEFAULT 'signet';" + ) + await db.execute("DROP INDEX IF EXISTS silnt.idx_sp_contacts_user") + await db.execute( + "CREATE INDEX idx_sp_contacts_user ON silnt.sp_contacts (user_id, network, value_sha256);" + ) diff --git a/models.py b/models.py index a9e665b..e1b84b7 100644 --- a/models.py +++ b/models.py @@ -1,10 +1,458 @@ -from pydantic import BaseModel +from typing import Optional, List +from fastapi import Query +from pydantic import BaseModel, Field +from datetime import datetime +import re +USERNAME_PATTERN = re.compile(r"^[a-z0-9_\-]{3,20}$") +RESERVED_USERNAMES = { + "admin", "administrator", "root", "support", "help", "info", "abuse", + "postmaster", "webmaster", "system", "anthropic", "claude", "bot", + "moderator", "mod", "service", "noreply", "no-reply", "test", "demo", +} +REQUEST_COOLDOWN_SECONDS = 24 * 3600 +RECENT_REJECT_COOLDOWN = 24 * 3600 -class Example(BaseModel): +class CreateWallet(BaseModel): + title: str + network: str = "mainnet" + hr_address: Optional[str] = None + mnemonic: Optional[str] = None + passphrase: Optional[str] = None + last_height: Optional[int] = None + +class BackendConfig(BaseModel): + blindbit_url: str = "" + mempool_url: str = "https://mempool.space" + min_scan_height: int = 0 # 0 = no minimum; e.g. 840000 = no scans before block 840000 + max_wallets_per_user: int = 1 # 0 = unlimited + dust_threshold_sats: int = 5000 + boltz_url: str = "" + fulcrum_host: str = "" + fulcrum_port: int = 50001 + fulcrum_tls: bool = False + login_scan_enabled: bool = True # auto catch-up scan on wallet open + login_scan_auto_threshold: int = 432 # gap < this => scan silently; >= => prompt + +class CreateWallet(BaseModel): + mnemonic: str = None + title: str = None + network: str = "mainnet" + passphrase: Optional[str] = "" + hr_address: Optional[str] = None + last_height: str = None + balance: Optional[int] = None + + +class WalletAccount(BaseModel): + id: str + user: str + title: str + balance: int + network: str = "mainnet" + sp_address: str + hr_address: str + last_height: int + # 0 = never scanned (matches the DB column default in migrations.py). Was 1, + # which made a freshly-created wallet report "scanned to block 1". + last_scan_height: int = 0 + + +class UTXORecord(BaseModel): + txid: str + vout: int + amount: int + priv_key_tweak: str + pub_key: str + utxo_state: str + timestamp: int + wallet_id: str + label: Optional[str] = None # individual utxo user created label + label_index: Optional[int] = None # None = main wallet, int = subaccount + frozen: bool = False + freeze_reason: Optional[str] + suspected_dust: bool = False + + +class ScanWalletRequest(BaseModel): + from_height: Optional[int] = None + to_height: Optional[int] = None + scan_secret: str + spend_key: str + + +class UtxoForTx(BaseModel): + txid: str + vout: int = 0 + amount: int + priv_key_tweak: str + pub_key: str + label: Optional[str] = None + +class BuildTxRequest(BaseModel): + wallet_id: str + recipient: str + amount: int + fee_rate: float = 1 + memo: str = "" + utxos: list[dict] + spend_key: str + scan_secret: str + + +class RecoverKeysRequest(BaseModel): + mnemonic: str # encrypted (AES-encrypted with last_height as key) + last_height: int # encryption key + birth height + passphrase: Optional[str] = None # NEW: BIP-39 passphrase (plaintext) + +class Config(BaseModel): + sats_denominated: bool = True + network: str = "mainnet" + + +class WalletAddress(BaseModel): + id: str + wallet_id: str + sp_address: str + label_index: int + hr_address: Optional[str] = None + created_at: int = 0 + + +class PreviewAddressRequest(BaseModel): + scan_secret: str + spend_key: str + label_index: Optional[int] = None # auto-picked if None + + +class SaveAddressRequest(BaseModel): + sp_address: str + label: Optional[str] = None + label_index: Optional[int] = None + + +class CloudflareConfig(BaseModel): + api_token: str = "" + zone_id: str = "" + domain: str = "" + + +class NtfyConfig(BaseModel): + enabled: bool = False + server_url: str = "https://ntfy.bitaurus.net" # base URL of the ntfy server + topics: List[str] = [] # one or more topics to publish to + access_token: str = "" # optional bearer token for protected topics + username: str = "" # HTTP Basic auth username (self-hosted servers) + password: str = "" # HTTP Basic auth password + priority: str = "default" # ntfy priority: min|low|default|high|urgent + + +class SetupBip353Request(BaseModel): + username: str # e.g. "alice" → alice@yourdomain.com + ttl: int = 300 # DNS TTL in seconds + +class ForgotPasswordRequest(BaseModel): + email: str + +class UpdateUtxoLabel(BaseModel): + label: str = '' + wallet_id: str + vout: Optional[int] = None + +class UpdateUtxoFrozenRequest(BaseModel): + frozen: bool + +class UpdateAddressLabelRequest(BaseModel): + label: Optional[str] = None + +class TrustedDevice(BaseModel): + id: str + user_id: str + device_id: str + user_agent: Optional[str] = None + ip: Optional[str] = None + label: Optional[str] = None + confirmed_at: int + last_seen_at: int + +class DeviceVerifyCodeRequest(BaseModel): + code: str + +class DeviceCheckResponse(BaseModel): + status: str # 'trusted' | 'pending' + device_count: int + cap: int + + +class DeviceConfirmResponse(BaseModel): + confirmed: bool + device_count: int + cap: int + device_id: str | None = None + +class DeviceListResponse(BaseModel): + devices: List[TrustedDevice] + current_device: Optional[str] = None + cap: int + +class WhoamiResponse(BaseModel): + user_id: str + username: Optional[str] = None + email: Optional[str] = None + is_admin: bool + +class UserPrefs(BaseModel): + user_id: str + dust_threshold_sats: Optional[int] = None + updated_at: int + +class UpdateUserPrefsRequest(BaseModel): + dust_threshold_sats: Optional[int] = None # None = revert to admin default + +class Bip353Request(BaseModel): + id: str + user_id: str + wallet_id: str + sp_address: str + requested_username: str + address_id: Optional[str] = None + final_username: Optional[str] = None + message: Optional[str] = None + status: str # 'pending' | 'approved' | 'rejected' | 'cancelled' + reject_reason: Optional[str] = None + created_at: int + processed_at: Optional[int] = None + processed_by: Optional[str] = None + + +class CreateBip353Request(BaseModel): + wallet_id: str + requested_username: str + address_id: Optional[str] = None # NULL = wallet base SP address + message: Optional[str] = Field(default=None, max_length=500) + +class ApproveBip353Request(BaseModel): + final_username: Optional[str] = None # admin may tweak the requested name + +class RejectBip353Request(BaseModel): + reason: str = Field(..., max_length=500) + +class BroadcastOutpoint(BaseModel): + txid: str + vout: int + +class BroadcastTxRequest(BaseModel): + tx_hex: str + wallet_id: str + # full outpoints of the inputs this tx spends + spent_outpoints: List[BroadcastOutpoint] = [] + # optional metadata for richer Activity display before rescan + recipient: Optional[str] = None + amount: Optional[int] = None + fee: Optional[int] = None + # backward-compat: older clients may still send spent_txids + spent_txids: Optional[List[str]] = None + +class RestoreUtxoRequest(BaseModel): + wallet_id: str + txid: str + vout: int + +# ── Models ──────────────────────────────────────────────────────────────────── +class CreateSwapInRequest(BaseModel): + wallet_id: str # LNbits wallet to receive the Lightning payment + amount: int # sats to receive on Lightning + refund_address: str # on-chain (non-SP) address to refund to on failure + silnt_wallet_id: Optional[str] = None # SP wallet funding the swap (for ownership/listing) + network: str + + +class SwapInResponse(BaseModel): + swap_id: str + address: str # on-chain lockup address to pay + expected_amount: int # exact sats to send on-chain (incl. Boltz fees) + timeout_block_height: Optional[int] = None + +class BoltzSwapRecord(BaseModel): + id: str + wallet_id: str # LNbits wallet (receives LN) + silnt_wallet_id: Optional[str] = None + network: Optional[str] = None + status: str = "created" # created|funded|failed|refunded|completed + # refund material: + refund_privkey: str # hex — SENSITIVE + refund_public_key: str # hex (what we sent to Boltz) + claim_public_key: str # hex (Boltz's key from create response) + swap_tree: dict # the swapTree Boltz returned (JSON) + timeout_block_height: Optional[int] = None + # lockup output to refund (filled once the on-chain lockup is observed): + lockup_txid: Optional[str] = None + lockup_vout: Optional[int] = None + lockup_value: Optional[int] = None + # bookkeeping: + address: Optional[str] = None # Boltz lockup address + expected_amount: Optional[int] = None + invoice: Optional[str] = None + payment_hash: Optional[str] = None + refund_address: Optional[str] = None # where the refund should go (user's on-chain addr) + +class RefundRequest(BaseModel): + address: Optional[str] = None # if omitted, use the address stored at create + fee_sats: int = 300 + +class FundedRequest(BaseModel): + lockup_txid: str # the SP-send tx that paid the Boltz lockup address + +class PayjoinDescriptor(BaseModel): + id: str + user_id: str + label: Optional[str] = None + descriptor: str # raw output descriptor (encrypted at rest) + xpub: str # parsed account xpub (encrypted at rest) + xpub_sha256: Optional[str] = None # non-reversible dedup tag + master_fp: str # 8 hex chars + account_path: str # e.g. "84h/1h/0h" + script_type: str = "wpkh" + network: str + last_sync_at: Optional[datetime] = None + created_at: Optional[datetime] = None + + +class PayjoinRequest(BaseModel): id: str - wallet: str + status: str = "PROPOSED" + sender_user_id: str + sender_username: str + receiver_user_id: Optional[str] = None + receiver_username: str + sender_descriptor_id: str + receiver_descriptor_id: Optional[str] = None + amount_sats: int + fee_rate: float + payment_address: str + receiver_input_sats: Optional[int] = None + fee_sats: Optional[int] = None + psbt: Optional[str] = None + unsigned_psbt: Optional[str] = None + receiver_signed_psbt: Optional[str] = None + sender_signed_psbt: Optional[str] = None + memo: Optional[str] = None + tx_hex: Optional[str] = None + txid: Optional[str] = None + sender_inputs: Optional[str] = None # JSON string + receiver_input: Optional[str] = None # JSON string + reject_reason: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + expires_at: Optional[int] = None + + +# ── request bodies (FastAPI) ────────────────────────────────────────────────── +class ImportDescriptorData(BaseModel): + descriptor: str + label: Optional[str] = None + network: str = "signet" + + +class ProposePayjoinData(BaseModel): + sender_descriptor_id: str + receiver_username: str + amount_sats: int + fee_rate: float + # sender's selected input outpoints to spend (from their synced UTXOs): + sender_inputs: list[dict] # [{txid, vout, value, chain, index}, ...] + +class AcceptPayjoinData(BaseModel): + receiver_descriptor_id: str + # the receiver's chosen contributed input: + receiver_input: dict + +class ContributePayjoinData(BaseModel): + receiver_descriptor_id: str + # the receiver's chosen contributed input: + receiver_input: dict # {txid, vout, value, chain, index} + # receiver's signed copy of the final unsigned PSBT (base64): + signed_psbt: str + + +class FinalizePayjoinData(BaseModel): + # sender's signed copy of the final unsigned PSBT (base64): + signed_psbt: str + +# ── invoice model (payee-initiated, directed PayJoin) ───────────────────────── +class CreateInvoiceData(BaseModel): + # A (payee) creates a directed invoice for a specific payer B. + receiver_descriptor_id: str # A's wallet that receives the payment + receiver_input: dict # A's ONE contributed input {txid,vout,value,chain,index} + payer_username: str # B, chosen from the dropdown + amount_sats: int # what B owes A + fee_rate: float + memo: Optional[str] = None + + +class PayInvoiceData(BaseModel): + # B (payer) pays an invoice: commits their wallet + inputs. siLNt then builds + # the merged PSBT. No signature yet — both sign the returned unsigned PSBT. + sender_descriptor_id: str # B's wallet to spend from + sender_inputs: list[dict] # B's selected inputs + + +class SignPayjoinData(BaseModel): + # either party submits their signed copy of the pristine unsigned PSBT; + # siLNt combines + broadcasts once BOTH are present (order-independent). + signed_psbt: str + + +# ── connections (consent-based curated payer/payee list) ────────────────────── +class PayjoinContact(BaseModel): + id: str + status: str + requester_user_id: str + target_user_id: str + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class CreateContactData(BaseModel): + username: str + + +class ContactLabelData(BaseModel): + label: str = "" # private label for a connection (blank clears it) + + +class SpContact(BaseModel): + id: str + user_id: str + network: str = "mainnet" # address book is per-network + label: str + kind: str # 'bitmail' | 'sp' + value: str # recipient (bitmail name or sp address); decrypted on read + value_sha256: Optional[str] = None + created_at: Optional[datetime] = None + last_used_at: Optional[datetime] = None + + +class CreateSpContactData(BaseModel): + label: str + value: str # 'name@domain' or 'sp1...'/'tsp1...' + + +class UpdateSpContactData(BaseModel): + label: str + +class AdminDeleteAccountData(BaseModel): + identifier: str # username or email of the account to delete + confirm_username: str # must match the resolved username (typed confirmation) + delete_bitmail: bool = True -class CreateExample(BaseModel): - wallet: str +class AdminAlert(BaseModel): + id: str + kind: str + severity: str = "warning" + title: str + detail: str = "" + meta: Optional[str] = None + acknowledged: bool = False + created_at: int \ No newline at end of file diff --git a/package.json b/package.json index 0a23031..35bc9f7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "example", + "name": "silnt", "version": "1.0.0", - "description": "For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)", + "description": "Silent Payments Wallet Extension", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" diff --git a/pyproject.toml b/pyproject.toml index d475b4a..b7099d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,22 @@ [project] -name = "lnbits-example" -version = "0.0.0" +name = "silnt" +version = "0.1.0" requires-python = ">=3.10,<3.13" -description = "LNbits, free and open-source Lightning wallet and accounts system." -authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] -urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/example" } +description = "Silent Payments OnChain Wallet for LNBits" +authors = [{ name = "Bitaurus", email = "ponthief@bitaurus.net" }] +urls = { Homepage = "https://bitaurus.net", Repository = "https://github.com/ponthief/silnt" } -dependencies = [ "lnbits>1" ] +dependencies = [ + "coincurve>=20.0.0", + "cryptography>=42.0.8", + "dnspython>=2.7.0", + "ecdsa>=0.19.1", + "embit>=0.8.0", + "fastapi>=0.115.13", + "httpx>=0.27.0", + "mnemonic>=0.21", + "pydantic>=1.10.22", +] [tool.uv] dev-dependencies = [ @@ -30,6 +40,7 @@ warn_untyped_fields = true [[tool.mypy.overrides]] module = [ + "embit.*", "lnbits.*", "lnurl.*", "loguru.*", @@ -38,6 +49,9 @@ module = [ "pyqrcode.*", "shortuuid.*", "httpx.*", + "coincurve.*", + "ecdsa.*", + "cryptography.*" ] ignore_missing_imports = "True" diff --git a/static/1.png b/static/1.png deleted file mode 100644 index 10b3673..0000000 Binary files a/static/1.png and /dev/null differ diff --git a/static/2.png b/static/2.png deleted file mode 100644 index c62770f..0000000 Binary files a/static/2.png and /dev/null differ diff --git a/static/3.png b/static/3.png deleted file mode 100644 index 343f926..0000000 Binary files a/static/3.png and /dev/null differ diff --git a/static/bitcoin-extension.png b/static/bitcoin-extension.png deleted file mode 100644 index 8366c0c..0000000 Binary files a/static/bitcoin-extension.png and /dev/null differ diff --git a/static/bitcoin-wallet.webp b/static/bitcoin-wallet.webp new file mode 100644 index 0000000..4ae7290 Binary files /dev/null and b/static/bitcoin-wallet.webp differ diff --git a/static/components/utxo-list.js b/static/components/utxo-list.js new file mode 100644 index 0000000..b317311 --- /dev/null +++ b/static/components/utxo-list.js @@ -0,0 +1,205 @@ +window.app.component('utxo-list', { + name: 'utxo-list', + template: '#utxo-list', + delimiters: ['${', '}'], + + props: [ + 'utxos', + 'accounts', + 'selectable', + 'payed-amount', + 'sats-denominated', + 'mempool-endpoint', + 'filter' + ], + + data: function () { + return { + utxosTable: { + columns: [ + { + name: 'expand', + align: 'left', + label: '' + }, + { + name: 'selected', + align: 'left', + label: '', + selectable: true + }, + { + name: 'status', + align: 'center', + label: 'Status', + field: 'utxo_state', + sortable: true + }, + { + name: 'amount', + align: 'left', + label: 'Amount', + field: 'amount', + sortable: true + }, + { + name: 'date', + align: 'left', + label: 'Date', + field: 'timestamp', + sortable: true, + sort: (a, b) => a - b + }, + { + name: 'label', + align: 'left', + label: 'Label', + field: 'label', + sortable: false + }, + ], + pagination: { + rowsPerPage: 10 + } + }, + utxoSelectionModes: [ + 'Manual', + 'Random', + 'Select All', + 'Smaller Inputs First', + 'Larger Inputs First' + ], + utxoSelectionMode: 'Random', + utxoSelectAmount: 0 + } + }, + + computed: { + columns: function () { + return this.utxosTable.columns.filter(c => + c.selectable ? this.selectable : true + ) + }, + unspentTotal: function () { + return (this.utxos || []) + .filter(u => u.utxo_state === 'unspent') + .reduce((t, u) => t + (u.amount || 0), 0) + }, + spentTotal: function () { + return (this.utxos || []) + .filter(u => u.utxo_state === 'spent') + .reduce((t, u) => t + (u.amount || 0), 0) + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + // Kept for compatibility but not used for blindbit UTXOs (no wallet account) + getWalletName: function (walletId) { + const wallet = (this.accounts || []).find(wl => wl.id === walletId) + return wallet ? wallet.title : 'unknown' + }, + getTotalSelectedUtxoAmount: function () { + const total = (this.utxos || []) + .filter(u => u.selected) + .reduce((t, a) => t + (a.amount || 0), 0) + return total + }, + refreshUtxoSelection: function (totalPayedAmount) { + this.utxoSelectAmount = totalPayedAmount + this.applyUtxoSelectionMode() + }, + unspentTotal: function () { + return (this.utxos || []) + .filter(u => u.utxo_state === 'unspent') + .reduce((t, u) => t + (u.amount || 0), 0) + }, + spentTotal: function () { + return (this.utxos || []) + .filter(u => u.utxo_state === 'spent') + .reduce((t, u) => t + (u.amount || 0), 0) + }, + formatTimestamp: function (timestamp) { + if (!timestamp) return 'N/A' + try { + const date = new Date(timestamp * 1000) + const day = String(date.getDate()).padStart(2, '0') + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] + const month = months[date.getMonth()] + const year = date.getFullYear() + const hours = String(date.getHours()).padStart(2, '0') + const mins = String(date.getMinutes()).padStart(2, '0') + return `${day} ${month} ${year} ${hours}:${mins}` + } catch (e) { + return 'Unknown' + } + }, + updateUtxoSelection: function () { + this.utxoSelectAmount = this.payedAmount + this.applyUtxoSelectionMode() + }, + startEditLabel: function (utxo) { + utxo.editingLabel = true + utxo.labelDraft = utxo.label || '' + }, + + cancelEditLabel: function (utxo) { + utxo.editingLabel = false + utxo.labelDraft = '' + }, + + saveLabel: async function (utxo) { + try { + await LNbits.api.request( + 'PUT', + `/siLNt/api/v1/utxos/${utxo.txid}/label`, + this.g.user.wallets[0].inkey, + { label: utxo.labelDraft || '' } + ) + utxo.label = utxo.labelDraft || '' + utxo.editingLabel = false + Quasar.Notify.create({ + type: 'positive', + message: utxo.label ? `Label set: ${utxo.label}` : 'Label cleared.', + timeout: 3000 + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + applyUtxoSelectionMode: function () { + const mode = this.utxoSelectionMode + const isSelectAll = mode === 'Select All' + if (isSelectAll) { + this.utxos.forEach(u => (u.selected = true)) + return + } + + const isManual = mode === 'Manual' + if (isManual || !this.utxoSelectAmount) return + + this.utxos.forEach(u => (u.selected = false)) + + const isSmallerFirst = mode === 'Smaller Inputs First' + const isLargerFirst = mode === 'Larger Inputs First' + let selectedUtxos = this.utxos.slice() + if (isSmallerFirst || isLargerFirst) { + const sortFn = isSmallerFirst + ? (a, b) => a.amount - b.amount + : (a, b) => b.amount - a.amount + selectedUtxos.sort(sortFn) + } else { + // default to random order + selectedUtxos = _.shuffle(selectedUtxos) + } + selectedUtxos.reduce((total, utxo) => { + utxo.selected = total < this.utxoSelectAmount + total += utxo.amount + return total + }, 0) + } + }, + created: async function () {} +}) \ No newline at end of file diff --git a/static/components/wallet-config.js b/static/components/wallet-config.js new file mode 100644 index 0000000..59a8bc1 --- /dev/null +++ b/static/components/wallet-config.js @@ -0,0 +1,73 @@ +window.app.component('wallet-config', { + name: 'wallet-config', + template: '#wallet-config', + delimiters: ['${', '}'], + + props: ['total', 'config-data', 'adminkey', 'inkey', 'canEditConfig'], + emits: ['update:config-data'], + data: function () { + return { + networkOptions: ['mainnet', 'testnet'], + internalConfig: {}, + show: false + } + }, + + computed: { + config: { + get() { + return this.internalConfig + }, + set(value) { + value.isLoaded = true + value.blindbit_url = value.blindbit_url || '' + value.mempool_url = value.mempool_url || 'https://mempool.space' + this.internalConfig = JSON.parse(JSON.stringify(value)) + this.$emit( + 'update:config-data', + JSON.parse(JSON.stringify(this.internalConfig)) + ) + } + } + }, + + methods: { + updateConfig: async function () { + if (!this.canEditConfig) return + try { + const {data} = await LNbits.api.request( + 'PUT', + '/siLNt/api/v1/backend/config', + this.adminkey, + this.config + ) + this.show = false + this.config = { + ...this.internalConfig, + ...data, + mempool_url: data.mempool_url || this.internalConfig.mempool_url || 'https://mempool.space' + } + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + getConfig: async function () { + try { + const [{data: blindbit}, {data: appConfig}] = await Promise.all([ + LNbits.api.request('GET', '/siLNt/api/v1/backend/config', this.inkey), + LNbits.api.request('GET', '/siLNt/api/v1/config', this.inkey) + ]) + this.config = { + ...blindbit, + ...appConfig, + mempool_url: blindbit.mempool_url || 'https://mempool.space' + } + } catch (error) { + LNbits.utils.notifyApiError(error) + } + } + }, + created: async function () { + await this.getConfig() + } +}) \ No newline at end of file diff --git a/static/components/wallet-list.js b/static/components/wallet-list.js new file mode 100644 index 0000000..1b70fd2 --- /dev/null +++ b/static/components/wallet-list.js @@ -0,0 +1,474 @@ +window.app.component('wallet-list', { + name: 'wallet-list', + template: '#wallet-list', + delimiters: ['${', '}'], + + props: [ + 'adminkey', + 'inkey', + 'sats-denominated', + // 'addresses', + 'network', + 'scannedUtxos' + ], + emits: ['accounts-update', 'fetch-utxos', 'scan-wallet', 'clear-utxos', 'open-bip353', 'send-wallet'], + data: function () { + return { + thrillaUrl: 'https://signet.thrilla.me', // or pull from server config + walletAccounts: [], + address: {}, + formDialog: { + show: false, + data: { + title: '', + hr_address: '', + last_height: '', + network: 'mainnet' + } + }, + updateDialog: { + show: false, + data: { + title: '', + hr_address: '', + last_height: '' + } + }, + qrDialog: { + show: false, + address: '' + }, + addressDialog: { + show: false, + wallet: null, + addresses: [], + generating: false + }, + bip353Valid: true, + showCreating: false, + showUpdating: false, + walletsTable: { + columns: [ + {name: 'sp_address', align: 'left', label: 'SP Address', field: 'sp_address'}, + { + name: 'title', + align: 'left', + label: 'Title', + field: 'title' + }, + { + name: 'hr_address', + align: 'left', + label: 'BIP353 Address', + field: 'hr_address' + }, + { + name: 'last_height', + align: 'left', + label: 'Height', + field: 'last_height' + }, + { + name: 'balance', + align: 'left', + label: 'Balance', + field: 'balance' + } + ], + pagination: { + rowsPerPage: 10 + } + } + } + }, + watch: { + scannedUtxos: { + deep: true, + handler(result) { + if (!result || !result.utxos || !result.utxos.length) return + + // Get wallet_id from the UTXOs themselves + const walletId = result.utxos[0]?.wallet_id + if (!walletId) return + + // Only update the specific wallet — not all wallets + const wallet = this.walletAccounts.find(w => w.id === walletId) + if (!wallet) return + + const newBalance = result.utxos + .filter(u => u.utxo_state === 'unspent') + .reduce((sum, u) => sum + (u.amount || 0), 0) + + if (newBalance === wallet.balance) return + + this.updateWalletBalance(wallet.id, newBalance) + wallet.balance = newBalance + } + } + }, + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + addWalletAccount: async function () { + this.showCreating = true + const data = _.omit(this.formDialog.data, 'wallet') + data.network = this.formDialog.data.network || this.network + // Validate BIP353 hr_address if provided + if (data.hr_address && data.hr_address.trim() !== '') { + const valid = await this.validateBip353(data.hr_address) + if (!valid) { + this.showCreating = false + return + } + } + data.mnemonic = CryptoJS.AES.encrypt(data.mnemonic, data.last_height).toString(); + await this.createWalletAccount(data) + this.showCreating = false + }, + updateWalletAccount: async function () { + this.showUpdating = true + const data = _.omit(this.updateDialog.data, 'wallet') + data.network = this.network + // Validate BIP353 hr_address if provided + if (data.hr_address) { + const valid = await this.validateBip353(data.hr_address) + if (!valid) { + this.showUpdating = false + return + } + } + if (data.id) { + await this.updateWalletDetails(data) + } + this.showUpdating = false + }, + createWalletAccount: async function (data) { + try { + const response = await LNbits.api.request( + 'POST', + '/siLNt/api/v1/wallet', + this.inkey, + data + ) + const r = response.data || {} + + this.formDialog.show = false + await this.refreshWalletAccounts() + + // If keys were returned, show a one-time copy dialog + if (r.scan_secret && r.spend_key) { + const keysJson = JSON.stringify({ + wallet_id: r.wallet_id, + scan_secret: r.scan_secret, + spend_key: r.spend_key, + }, null, 2) + + Quasar.Dialog.create({ + title: 'Wallet Keys — Save These Now', + message: + 'Your wallet has been created. The keys below are shown ONCE and ' + + 'will NOT be stored on the server.\\n\\n' + + 'To use this wallet, open the Thrilla app, recover the wallet ' + + 'with your mnemonic, or paste these keys directly.\\n\\n' + + 'If you lose them, you\'ll need to re-import from the mnemonic.', + html: false, + message: `
${keysJson}
`, + ok: { + label: 'I have saved them', + color: 'positive', + }, + persistent: true, + }) + + Quasar.copyToClipboard(keysJson) + this.$q.notify({ + type: 'positive', + message: 'Wallet keys copied to clipboard. Save them somewhere safe.', + timeout: 10000, + }) + } else { + this.$q.notify({ + type: 'positive', + message: 'Silent Payment Wallet created.', + timeout: 5000, + }) + } + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + updateWalletDialog(walletAccountId) { + var wallet = _.findWhere(this.walletAccounts, {id: walletAccountId}) + this.updateDialog.data = _.clone(wallet) + this.updateDialog.hr_address = this.updateDialog.data.hr_address + this.updateDialog.last_height = this.updateDialog.data.last_height + this.updateDialog.title = this.updateDialog.data.title + this.updateDialog.show = true + this.bip353Valid = true + }, + updateWalletDetails: async function (data) { + try { + const response = await LNbits.api.request( + 'PUT', + '/siLNt/api/v1/wallet/' + data.id, + this.inkey, + data + ) + this.walletAccounts.push(mapWalletAccount(response.data)) + this.updateDialog.show = false + await this.refreshWalletAccounts() + Quasar.Notify.create({ + type: 'positive', + message: 'Wallet updated.' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + deleteWalletDialog: function (walletAccountId) { + LNbits.utils + .confirmDialog( + 'Are you sure you want to delete this wallet?' + ) + .onOk(async () => { + try { + await LNbits.api.request( + 'DELETE', + '/siLNt/api/v1/wallet/' + walletAccountId, + this.inkey + ) + this.walletAccounts = _.reject(this.walletAccounts, function (obj) { + return obj.id === walletAccountId + }) + await this.refreshWalletAccounts() + this.$emit('clear-utxos',walletAccountId) + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Error while deleting wallet account. Please try again.', + timeout: 10000 + }) + } + }) + }, + getsiLNtWallets: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + `/siLNt/api/v1/wallet`, + this.inkey + ) + return data + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to fetch wallets.', + timeout: 10000 + }) + LNbits.utils.notifyApiError(error) + } + return [] + }, + refreshWalletAccounts: async function () { + this.walletAccounts = [] + const wallets = await this.getsiLNtWallets() + // Load addresses for each wallet in parallel + const walletsWithAddresses = await Promise.all( + wallets.map(async w => { + let addresses = [] + try { + const {data} = await LNbits.api.request( + 'GET', + `/siLNt/api/v1/wallet/${w.id}/addresses`, + this.inkey + ) + addresses = data.addresses || [] + } catch (e) { + // ignore — wallet still loads without addresses + } + return { + ...mapWalletAccount(w), + expanded: false, + addresses: addresses, + loadingAddresses: false + } + }) + ) + this.walletAccounts = walletsWithAddresses + this.$emit('accounts-update', this.walletAccounts) + }, + updateWalletBalance: async function (walletId, balance) { + try { + await LNbits.api.request( + 'PUT', + `/siLNt/api/v1/wallet/${walletId}`, + this.inkey, + { balance } + ) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + validateBip353: async function (address) { + // Basic email format check first + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(address)) { + this.$q.notify({ + type: 'warning', + message: 'BIP353 Address must be in email format (e.g. alice@domain.com)', + timeout: 8000 + }) + // this.bip353Valid = false + return false + } + try { + await LNbits.api.request( + 'GET', + `/siLNt/api/v1/bip353/resolve?address=${encodeURIComponent(address)}`, + this.inkey + ) + this.$q.notify({ + type: 'positive', + message: `BIP353 address verified: ${address}`, + timeout: 5000 + }) + this.bip353Valid = true + return true + } catch (error) { + this.$q.notify({ + type: 'negative', + message: `BIP353 resolution failed for ${address} — check the address and try again`, + timeout: 8000 + }) + // this.bip353Valid = false + return false + } + }, + toggleAddresses: async function (wallet) { + wallet.expanded = !wallet.expanded + if (wallet.expanded && !wallet.addresses.length && !wallet.loadingAddresses) { + wallet.loadingAddresses = true + try { + const {data} = await LNbits.api.request( + 'GET', + `/siLNt/api/v1/wallet/${wallet.id}/addresses`, + this.inkey + ) + wallet.addresses = data.addresses || [] + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + wallet.loadingAddresses = false + } + } + }, + generateAddress: async function (wallet) { + this.$q.notify({ + type: 'info', + message: 'Open this wallet in the Thrilla app to generate a labeled address.', + caption: 'LNbits doesn\'t store wallet keys for security reasons.', + timeout: 7000, + actions: [{ + label: 'Open Thrilla', + color: 'white', + handler: () => window.open(this.thrillaUrl, '_blank'), + }], + }) + }, + + saveAddress: async function (wallet, addr) { + const rw = this.walletAccounts.find(w => w.id === wallet.id) + if (!rw) return + try { + const {data} = await LNbits.api.request('POST', `/siLNt/api/v1/wallet/${rw.id}/addresses`, this.inkey, { + sp_address: addr.sp_address, + label_index: addr.label_index + }) + const idx = rw.addresses.findIndex(a => a._tempId === addr._tempId) + if (idx !== -1) rw.addresses.splice(idx, 1, { ...data, saved: true }) + this.$emit('addresses-changed') + Quasar.Notify.create({ type: 'positive', message: 'Address saved.' }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + + deleteAddress: async function (wallet, addr) { + const rw = this.walletAccounts.find(w => w.id === wallet.id) + if (!rw) return + if (!addr.id) { + rw.addresses = rw.addresses.filter(a => a._tempId !== addr._tempId) + return + } + LNbits.utils.confirmDialog('Delete this labeled address?').onOk(async () => { + try { + await LNbits.api.request('DELETE', `/siLNt/api/v1/wallet/${rw.id}/addresses/${addr.id}`, this.inkey) + rw.addresses = rw.addresses.filter(a => a.id !== addr.id) + Quasar.Notify.create({ type: 'positive', message: 'Address deleted.' }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }) + }, + created: async function () { + if (this.inkey) { + await this.refreshWalletAccounts() + } + }, + showQrCodeAddress: function (address) { + this.qrDialog.address = address + this.qrDialog.show = true + }, + closeFormDialog: function () { + this.formDialog.data = { + hr_address: '', // ← empty string not null + last_height: '', + mnemonic: '', + network: 'mainnet', + is_unique: false + } + }, + closeUpdateDialog: function () { + this.updateDialog.data = { + is_unique: false + } + }, + getAccountDescription: function (accountType) { + return getAccountDescription(accountType) + }, + + showAddAccountDialog: function () { + this.formDialog.data = { + hr_address: '', + last_height: '', + mnemonic: '', + network: 'mainnet' + } + this.formDialog.show = true + }, + showUpdateWalletDialog: function () { + this.updateDialog.show = true + }, + // todo: bad. base.js not present in custom components + copyText: function (text, message, position) { + var notify = this.$q.notify + Quasar.copyToClipboard(text).then(function () { + notify({ + message: message || 'Copied to clipboard!', + position: position || 'bottom' + }) + }) + }, + showQrCode: function (wallet) { + this.qrDialog.address = wallet.sp_address || '' + this.qrDialog.show = true + }, + }, + created: async function () { + if (this.inkey) { + await this.refreshWalletAccounts() + } + } +}) diff --git a/static/conversion-example.png b/static/conversion-example.png deleted file mode 100644 index 544e633..0000000 Binary files a/static/conversion-example.png and /dev/null differ diff --git a/static/conversion-example2.png b/static/conversion-example2.png deleted file mode 100644 index 7543fdc..0000000 Binary files a/static/conversion-example2.png and /dev/null differ diff --git a/static/fastapi-example.png b/static/fastapi-example.png deleted file mode 100644 index ed309bb..0000000 Binary files a/static/fastapi-example.png and /dev/null differ diff --git a/static/fastapi-example2.png b/static/fastapi-example2.png deleted file mode 100644 index ceacf49..0000000 Binary files a/static/fastapi-example2.png and /dev/null differ diff --git a/static/fastapi-framework.png b/static/fastapi-framework.png deleted file mode 100644 index b446ce2..0000000 Binary files a/static/fastapi-framework.png and /dev/null differ diff --git a/static/fastapilogo.png b/static/fastapilogo.png deleted file mode 100644 index 57eb6a8..0000000 Binary files a/static/fastapilogo.png and /dev/null differ diff --git a/static/js/bip39-word-list.js b/static/js/bip39-word-list.js new file mode 100644 index 0000000..c0a5eac --- /dev/null +++ b/static/js/bip39-word-list.js @@ -0,0 +1,2050 @@ +const bip39WordList = Object.freeze([ + 'abandon', + 'ability', + 'able', + 'about', + 'above', + 'absent', + 'absorb', + 'abstract', + 'absurd', + 'abuse', + 'access', + 'accident', + 'account', + 'accuse', + 'achieve', + 'acid', + 'acoustic', + 'acquire', + 'across', + 'act', + 'action', + 'actor', + 'actress', + 'actual', + 'adapt', + 'add', + 'addict', + 'address', + 'adjust', + 'admit', + 'adult', + 'advance', + 'advice', + 'aerobic', + 'affair', + 'afford', + 'afraid', + 'again', + 'age', + 'agent', + 'agree', + 'ahead', + 'aim', + 'air', + 'airport', + 'aisle', + 'alarm', + 'album', + 'alcohol', + 'alert', + 'alien', + 'all', + 'alley', + 'allow', + 'almost', + 'alone', + 'alpha', + 'already', + 'also', + 'alter', + 'always', + 'amateur', + 'amazing', + 'among', + 'amount', + 'amused', + 'analyst', + 'anchor', + 'ancient', + 'anger', + 'angle', + 'angry', + 'animal', + 'ankle', + 'announce', + 'annual', + 'another', + 'answer', + 'antenna', + 'antique', + 'anxiety', + 'any', + 'apart', + 'apology', + 'appear', + 'apple', + 'approve', + 'april', + 'arch', + 'arctic', + 'area', + 'arena', + 'argue', + 'arm', + 'armed', + 'armor', + 'army', + 'around', + 'arrange', + 'arrest', + 'arrive', + 'arrow', + 'art', + 'artefact', + 'artist', + 'artwork', + 'ask', + 'aspect', + 'assault', + 'asset', + 'assist', + 'assume', + 'asthma', + 'athlete', + 'atom', + 'attack', + 'attend', + 'attitude', + 'attract', + 'auction', + 'audit', + 'august', + 'aunt', + 'author', + 'auto', + 'autumn', + 'average', + 'avocado', + 'avoid', + 'awake', + 'aware', + 'away', + 'awesome', + 'awful', + 'awkward', + 'axis', + 'baby', + 'bachelor', + 'bacon', + 'badge', + 'bag', + 'balance', + 'balcony', + 'ball', + 'bamboo', + 'banana', + 'banner', + 'bar', + 'barely', + 'bargain', + 'barrel', + 'base', + 'basic', + 'basket', + 'battle', + 'beach', + 'bean', + 'beauty', + 'because', + 'become', + 'beef', + 'before', + 'begin', + 'behave', + 'behind', + 'believe', + 'below', + 'belt', + 'bench', + 'benefit', + 'best', + 'betray', + 'better', + 'between', + 'beyond', + 'bicycle', + 'bid', + 'bike', + 'bind', + 'biology', + 'bird', + 'birth', + 'bitter', + 'black', + 'blade', + 'blame', + 'blanket', + 'blast', + 'bleak', + 'bless', + 'blind', + 'blood', + 'blossom', + 'blouse', + 'blue', + 'blur', + 'blush', + 'board', + 'boat', + 'body', + 'boil', + 'bomb', + 'bone', + 'bonus', + 'book', + 'boost', + 'border', + 'boring', + 'borrow', + 'boss', + 'bottom', + 'bounce', + 'box', + 'boy', + 'bracket', + 'brain', + 'brand', + 'brass', + 'brave', + 'bread', + 'breeze', + 'brick', + 'bridge', + 'brief', + 'bright', + 'bring', + 'brisk', + 'broccoli', + 'broken', + 'bronze', + 'broom', + 'brother', + 'brown', + 'brush', + 'bubble', + 'buddy', + 'budget', + 'buffalo', + 'build', + 'bulb', + 'bulk', + 'bullet', + 'bundle', + 'bunker', + 'burden', + 'burger', + 'burst', + 'bus', + 'business', + 'busy', + 'butter', + 'buyer', + 'buzz', + 'cabbage', + 'cabin', + 'cable', + 'cactus', + 'cage', + 'cake', + 'call', + 'calm', + 'camera', + 'camp', + 'can', + 'canal', + 'cancel', + 'candy', + 'cannon', + 'canoe', + 'canvas', + 'canyon', + 'capable', + 'capital', + 'captain', + 'car', + 'carbon', + 'card', + 'cargo', + 'carpet', + 'carry', + 'cart', + 'case', + 'cash', + 'casino', + 'castle', + 'casual', + 'cat', + 'catalog', + 'catch', + 'category', + 'cattle', + 'caught', + 'cause', + 'caution', + 'cave', + 'ceiling', + 'celery', + 'cement', + 'census', + 'century', + 'cereal', + 'certain', + 'chair', + 'chalk', + 'champion', + 'change', + 'chaos', + 'chapter', + 'charge', + 'chase', + 'chat', + 'cheap', + 'check', + 'cheese', + 'chef', + 'cherry', + 'chest', + 'chicken', + 'chief', + 'child', + 'chimney', + 'choice', + 'choose', + 'chronic', + 'chuckle', + 'chunk', + 'churn', + 'cigar', + 'cinnamon', + 'circle', + 'citizen', + 'city', + 'civil', + 'claim', + 'clap', + 'clarify', + 'claw', + 'clay', + 'clean', + 'clerk', + 'clever', + 'click', + 'client', + 'cliff', + 'climb', + 'clinic', + 'clip', + 'clock', + 'clog', + 'close', + 'cloth', + 'cloud', + 'clown', + 'club', + 'clump', + 'cluster', + 'clutch', + 'coach', + 'coast', + 'coconut', + 'code', + 'coffee', + 'coil', + 'coin', + 'collect', + 'color', + 'column', + 'combine', + 'come', + 'comfort', + 'comic', + 'common', + 'company', + 'concert', + 'conduct', + 'confirm', + 'congress', + 'connect', + 'consider', + 'control', + 'convince', + 'cook', + 'cool', + 'copper', + 'copy', + 'coral', + 'core', + 'corn', + 'correct', + 'cost', + 'cotton', + 'couch', + 'country', + 'couple', + 'course', + 'cousin', + 'cover', + 'coyote', + 'crack', + 'cradle', + 'craft', + 'cram', + 'crane', + 'crash', + 'crater', + 'crawl', + 'crazy', + 'cream', + 'credit', + 'creek', + 'crew', + 'cricket', + 'crime', + 'crisp', + 'critic', + 'crop', + 'cross', + 'crouch', + 'crowd', + 'crucial', + 'cruel', + 'cruise', + 'crumble', + 'crunch', + 'crush', + 'cry', + 'crystal', + 'cube', + 'culture', + 'cup', + 'cupboard', + 'curious', + 'current', + 'curtain', + 'curve', + 'cushion', + 'custom', + 'cute', + 'cycle', + 'dad', + 'damage', + 'damp', + 'dance', + 'danger', + 'daring', + 'dash', + 'daughter', + 'dawn', + 'day', + 'deal', + 'debate', + 'debris', + 'decade', + 'december', + 'decide', + 'decline', + 'decorate', + 'decrease', + 'deer', + 'defense', + 'define', + 'defy', + 'degree', + 'delay', + 'deliver', + 'demand', + 'demise', + 'denial', + 'dentist', + 'deny', + 'depart', + 'depend', + 'deposit', + 'depth', + 'deputy', + 'derive', + 'describe', + 'desert', + 'design', + 'desk', + 'despair', + 'destroy', + 'detail', + 'detect', + 'develop', + 'device', + 'devote', + 'diagram', + 'dial', + 'diamond', + 'diary', + 'dice', + 'diesel', + 'diet', + 'differ', + 'digital', + 'dignity', + 'dilemma', + 'dinner', + 'dinosaur', + 'direct', + 'dirt', + 'disagree', + 'discover', + 'disease', + 'dish', + 'dismiss', + 'disorder', + 'display', + 'distance', + 'divert', + 'divide', + 'divorce', + 'dizzy', + 'doctor', + 'document', + 'dog', + 'doll', + 'dolphin', + 'domain', + 'donate', + 'donkey', + 'donor', + 'door', + 'dose', + 'double', + 'dove', + 'draft', + 'dragon', + 'drama', + 'drastic', + 'draw', + 'dream', + 'dress', + 'drift', + 'drill', + 'drink', + 'drip', + 'drive', + 'drop', + 'drum', + 'dry', + 'duck', + 'dumb', + 'dune', + 'during', + 'dust', + 'dutch', + 'duty', + 'dwarf', + 'dynamic', + 'eager', + 'eagle', + 'early', + 'earn', + 'earth', + 'easily', + 'east', + 'easy', + 'echo', + 'ecology', + 'economy', + 'edge', + 'edit', + 'educate', + 'effort', + 'egg', + 'eight', + 'either', + 'elbow', + 'elder', + 'electric', + 'elegant', + 'element', + 'elephant', + 'elevator', + 'elite', + 'else', + 'embark', + 'embody', + 'embrace', + 'emerge', + 'emotion', + 'employ', + 'empower', + 'empty', + 'enable', + 'enact', + 'end', + 'endless', + 'endorse', + 'enemy', + 'energy', + 'enforce', + 'engage', + 'engine', + 'enhance', + 'enjoy', + 'enlist', + 'enough', + 'enrich', + 'enroll', + 'ensure', + 'enter', + 'entire', + 'entry', + 'envelope', + 'episode', + 'equal', + 'equip', + 'era', + 'erase', + 'erode', + 'erosion', + 'error', + 'erupt', + 'escape', + 'essay', + 'essence', + 'estate', + 'eternal', + 'ethics', + 'evidence', + 'evil', + 'evoke', + 'evolve', + 'exact', + 'example', + 'excess', + 'exchange', + 'excite', + 'exclude', + 'excuse', + 'execute', + 'exercise', + 'exhaust', + 'exhibit', + 'exile', + 'exist', + 'exit', + 'exotic', + 'expand', + 'expect', + 'expire', + 'explain', + 'expose', + 'express', + 'extend', + 'extra', + 'eye', + 'eyebrow', + 'fabric', + 'face', + 'faculty', + 'fade', + 'faint', + 'faith', + 'fall', + 'false', + 'fame', + 'family', + 'famous', + 'fan', + 'fancy', + 'fantasy', + 'farm', + 'fashion', + 'fat', + 'fatal', + 'father', + 'fatigue', + 'fault', + 'favorite', + 'feature', + 'february', + 'federal', + 'fee', + 'feed', + 'feel', + 'female', + 'fence', + 'festival', + 'fetch', + 'fever', + 'few', + 'fiber', + 'fiction', + 'field', + 'figure', + 'file', + 'film', + 'filter', + 'final', + 'find', + 'fine', + 'finger', + 'finish', + 'fire', + 'firm', + 'first', + 'fiscal', + 'fish', + 'fit', + 'fitness', + 'fix', + 'flag', + 'flame', + 'flash', + 'flat', + 'flavor', + 'flee', + 'flight', + 'flip', + 'float', + 'flock', + 'floor', + 'flower', + 'fluid', + 'flush', + 'fly', + 'foam', + 'focus', + 'fog', + 'foil', + 'fold', + 'follow', + 'food', + 'foot', + 'force', + 'forest', + 'forget', + 'fork', + 'fortune', + 'forum', + 'forward', + 'fossil', + 'foster', + 'found', + 'fox', + 'fragile', + 'frame', + 'frequent', + 'fresh', + 'friend', + 'fringe', + 'frog', + 'front', + 'frost', + 'frown', + 'frozen', + 'fruit', + 'fuel', + 'fun', + 'funny', + 'furnace', + 'fury', + 'future', + 'gadget', + 'gain', + 'galaxy', + 'gallery', + 'game', + 'gap', + 'garage', + 'garbage', + 'garden', + 'garlic', + 'garment', + 'gas', + 'gasp', + 'gate', + 'gather', + 'gauge', + 'gaze', + 'general', + 'genius', + 'genre', + 'gentle', + 'genuine', + 'gesture', + 'ghost', + 'giant', + 'gift', + 'giggle', + 'ginger', + 'giraffe', + 'girl', + 'give', + 'glad', + 'glance', + 'glare', + 'glass', + 'glide', + 'glimpse', + 'globe', + 'gloom', + 'glory', + 'glove', + 'glow', + 'glue', + 'goat', + 'goddess', + 'gold', + 'good', + 'goose', + 'gorilla', + 'gospel', + 'gossip', + 'govern', + 'gown', + 'grab', + 'grace', + 'grain', + 'grant', + 'grape', + 'grass', + 'gravity', + 'great', + 'green', + 'grid', + 'grief', + 'grit', + 'grocery', + 'group', + 'grow', + 'grunt', + 'guard', + 'guess', + 'guide', + 'guilt', + 'guitar', + 'gun', + 'gym', + 'habit', + 'hair', + 'half', + 'hammer', + 'hamster', + 'hand', + 'happy', + 'harbor', + 'hard', + 'harsh', + 'harvest', + 'hat', + 'have', + 'hawk', + 'hazard', + 'head', + 'health', + 'heart', + 'heavy', + 'hedgehog', + 'height', + 'hello', + 'helmet', + 'help', + 'hen', + 'hero', + 'hidden', + 'high', + 'hill', + 'hint', + 'hip', + 'hire', + 'history', + 'hobby', + 'hockey', + 'hold', + 'hole', + 'holiday', + 'hollow', + 'home', + 'honey', + 'hood', + 'hope', + 'horn', + 'horror', + 'horse', + 'hospital', + 'host', + 'hotel', + 'hour', + 'hover', + 'hub', + 'huge', + 'human', + 'humble', + 'humor', + 'hundred', + 'hungry', + 'hunt', + 'hurdle', + 'hurry', + 'hurt', + 'husband', + 'hybrid', + 'ice', + 'icon', + 'idea', + 'identify', + 'idle', + 'ignore', + 'ill', + 'illegal', + 'illness', + 'image', + 'imitate', + 'immense', + 'immune', + 'impact', + 'impose', + 'improve', + 'impulse', + 'inch', + 'include', + 'income', + 'increase', + 'index', + 'indicate', + 'indoor', + 'industry', + 'infant', + 'inflict', + 'inform', + 'inhale', + 'inherit', + 'initial', + 'inject', + 'injury', + 'inmate', + 'inner', + 'innocent', + 'input', + 'inquiry', + 'insane', + 'insect', + 'inside', + 'inspire', + 'install', + 'intact', + 'interest', + 'into', + 'invest', + 'invite', + 'involve', + 'iron', + 'island', + 'isolate', + 'issue', + 'item', + 'ivory', + 'jacket', + 'jaguar', + 'jar', + 'jazz', + 'jealous', + 'jeans', + 'jelly', + 'jewel', + 'job', + 'join', + 'joke', + 'journey', + 'joy', + 'judge', + 'juice', + 'jump', + 'jungle', + 'junior', + 'junk', + 'just', + 'kangaroo', + 'keen', + 'keep', + 'ketchup', + 'key', + 'kick', + 'kid', + 'kidney', + 'kind', + 'kingdom', + 'kiss', + 'kit', + 'kitchen', + 'kite', + 'kitten', + 'kiwi', + 'knee', + 'knife', + 'knock', + 'know', + 'lab', + 'label', + 'labor', + 'ladder', + 'lady', + 'lake', + 'lamp', + 'language', + 'laptop', + 'large', + 'later', + 'latin', + 'laugh', + 'laundry', + 'lava', + 'law', + 'lawn', + 'lawsuit', + 'layer', + 'lazy', + 'leader', + 'leaf', + 'learn', + 'leave', + 'lecture', + 'left', + 'leg', + 'legal', + 'legend', + 'leisure', + 'lemon', + 'lend', + 'length', + 'lens', + 'leopard', + 'lesson', + 'letter', + 'level', + 'liar', + 'liberty', + 'library', + 'license', + 'life', + 'lift', + 'light', + 'like', + 'limb', + 'limit', + 'link', + 'lion', + 'liquid', + 'list', + 'little', + 'live', + 'lizard', + 'load', + 'loan', + 'lobster', + 'local', + 'lock', + 'logic', + 'lonely', + 'long', + 'loop', + 'lottery', + 'loud', + 'lounge', + 'love', + 'loyal', + 'lucky', + 'luggage', + 'lumber', + 'lunar', + 'lunch', + 'luxury', + 'lyrics', + 'machine', + 'mad', + 'magic', + 'magnet', + 'maid', + 'mail', + 'main', + 'major', + 'make', + 'mammal', + 'man', + 'manage', + 'mandate', + 'mango', + 'mansion', + 'manual', + 'maple', + 'marble', + 'march', + 'margin', + 'marine', + 'market', + 'marriage', + 'mask', + 'mass', + 'master', + 'match', + 'material', + 'math', + 'matrix', + 'matter', + 'maximum', + 'maze', + 'meadow', + 'mean', + 'measure', + 'meat', + 'mechanic', + 'medal', + 'media', + 'melody', + 'melt', + 'member', + 'memory', + 'mention', + 'menu', + 'mercy', + 'merge', + 'merit', + 'merry', + 'mesh', + 'message', + 'metal', + 'method', + 'middle', + 'midnight', + 'milk', + 'million', + 'mimic', + 'mind', + 'minimum', + 'minor', + 'minute', + 'miracle', + 'mirror', + 'misery', + 'miss', + 'mistake', + 'mix', + 'mixed', + 'mixture', + 'mobile', + 'model', + 'modify', + 'mom', + 'moment', + 'monitor', + 'monkey', + 'monster', + 'month', + 'moon', + 'moral', + 'more', + 'morning', + 'mosquito', + 'mother', + 'motion', + 'motor', + 'mountain', + 'mouse', + 'move', + 'movie', + 'much', + 'muffin', + 'mule', + 'multiply', + 'muscle', + 'museum', + 'mushroom', + 'music', + 'must', + 'mutual', + 'myself', + 'mystery', + 'myth', + 'naive', + 'name', + 'napkin', + 'narrow', + 'nasty', + 'nation', + 'nature', + 'near', + 'neck', + 'need', + 'negative', + 'neglect', + 'neither', + 'nephew', + 'nerve', + 'nest', + 'net', + 'network', + 'neutral', + 'never', + 'news', + 'next', + 'nice', + 'night', + 'noble', + 'noise', + 'nominee', + 'noodle', + 'normal', + 'north', + 'nose', + 'notable', + 'note', + 'nothing', + 'notice', + 'novel', + 'now', + 'nuclear', + 'number', + 'nurse', + 'nut', + 'oak', + 'obey', + 'object', + 'oblige', + 'obscure', + 'observe', + 'obtain', + 'obvious', + 'occur', + 'ocean', + 'october', + 'odor', + 'off', + 'offer', + 'office', + 'often', + 'oil', + 'okay', + 'old', + 'olive', + 'olympic', + 'omit', + 'once', + 'one', + 'onion', + 'online', + 'only', + 'open', + 'opera', + 'opinion', + 'oppose', + 'option', + 'orange', + 'orbit', + 'orchard', + 'order', + 'ordinary', + 'organ', + 'orient', + 'original', + 'orphan', + 'ostrich', + 'other', + 'outdoor', + 'outer', + 'output', + 'outside', + 'oval', + 'oven', + 'over', + 'own', + 'owner', + 'oxygen', + 'oyster', + 'ozone', + 'pact', + 'paddle', + 'page', + 'pair', + 'palace', + 'palm', + 'panda', + 'panel', + 'panic', + 'panther', + 'paper', + 'parade', + 'parent', + 'park', + 'parrot', + 'party', + 'pass', + 'patch', + 'path', + 'patient', + 'patrol', + 'pattern', + 'pause', + 'pave', + 'payment', + 'peace', + 'peanut', + 'pear', + 'peasant', + 'pelican', + 'pen', + 'penalty', + 'pencil', + 'people', + 'pepper', + 'perfect', + 'permit', + 'person', + 'pet', + 'phone', + 'photo', + 'phrase', + 'physical', + 'piano', + 'picnic', + 'picture', + 'piece', + 'pig', + 'pigeon', + 'pill', + 'pilot', + 'pink', + 'pioneer', + 'pipe', + 'pistol', + 'pitch', + 'pizza', + 'place', + 'planet', + 'plastic', + 'plate', + 'play', + 'please', + 'pledge', + 'pluck', + 'plug', + 'plunge', + 'poem', + 'poet', + 'point', + 'polar', + 'pole', + 'police', + 'pond', + 'pony', + 'pool', + 'popular', + 'portion', + 'position', + 'possible', + 'post', + 'potato', + 'pottery', + 'poverty', + 'powder', + 'power', + 'practice', + 'praise', + 'predict', + 'prefer', + 'prepare', + 'present', + 'pretty', + 'prevent', + 'price', + 'pride', + 'primary', + 'print', + 'priority', + 'prison', + 'private', + 'prize', + 'problem', + 'process', + 'produce', + 'profit', + 'program', + 'project', + 'promote', + 'proof', + 'property', + 'prosper', + 'protect', + 'proud', + 'provide', + 'public', + 'pudding', + 'pull', + 'pulp', + 'pulse', + 'pumpkin', + 'punch', + 'pupil', + 'puppy', + 'purchase', + 'purity', + 'purpose', + 'purse', + 'push', + 'put', + 'puzzle', + 'pyramid', + 'quality', + 'quantum', + 'quarter', + 'question', + 'quick', + 'quit', + 'quiz', + 'quote', + 'rabbit', + 'raccoon', + 'race', + 'rack', + 'radar', + 'radio', + 'rail', + 'rain', + 'raise', + 'rally', + 'ramp', + 'ranch', + 'random', + 'range', + 'rapid', + 'rare', + 'rate', + 'rather', + 'raven', + 'raw', + 'razor', + 'ready', + 'real', + 'reason', + 'rebel', + 'rebuild', + 'recall', + 'receive', + 'recipe', + 'record', + 'recycle', + 'reduce', + 'reflect', + 'reform', + 'refuse', + 'region', + 'regret', + 'regular', + 'reject', + 'relax', + 'release', + 'relief', + 'rely', + 'remain', + 'remember', + 'remind', + 'remove', + 'render', + 'renew', + 'rent', + 'reopen', + 'repair', + 'repeat', + 'replace', + 'report', + 'require', + 'rescue', + 'resemble', + 'resist', + 'resource', + 'response', + 'result', + 'retire', + 'retreat', + 'return', + 'reunion', + 'reveal', + 'review', + 'reward', + 'rhythm', + 'rib', + 'ribbon', + 'rice', + 'rich', + 'ride', + 'ridge', + 'rifle', + 'right', + 'rigid', + 'ring', + 'riot', + 'ripple', + 'risk', + 'ritual', + 'rival', + 'river', + 'road', + 'roast', + 'robot', + 'robust', + 'rocket', + 'romance', + 'roof', + 'rookie', + 'room', + 'rose', + 'rotate', + 'rough', + 'round', + 'route', + 'royal', + 'rubber', + 'rude', + 'rug', + 'rule', + 'run', + 'runway', + 'rural', + 'sad', + 'saddle', + 'sadness', + 'safe', + 'sail', + 'salad', + 'salmon', + 'salon', + 'salt', + 'salute', + 'same', + 'sample', + 'sand', + 'satisfy', + 'satoshi', + 'sauce', + 'sausage', + 'save', + 'say', + 'scale', + 'scan', + 'scare', + 'scatter', + 'scene', + 'scheme', + 'school', + 'science', + 'scissors', + 'scorpion', + 'scout', + 'scrap', + 'screen', + 'script', + 'scrub', + 'sea', + 'search', + 'season', + 'seat', + 'second', + 'secret', + 'section', + 'security', + 'seed', + 'seek', + 'segment', + 'select', + 'sell', + 'seminar', + 'senior', + 'sense', + 'sentence', + 'series', + 'service', + 'session', + 'settle', + 'setup', + 'seven', + 'shadow', + 'shaft', + 'shallow', + 'share', + 'shed', + 'shell', + 'sheriff', + 'shield', + 'shift', + 'shine', + 'ship', + 'shiver', + 'shock', + 'shoe', + 'shoot', + 'shop', + 'short', + 'shoulder', + 'shove', + 'shrimp', + 'shrug', + 'shuffle', + 'shy', + 'sibling', + 'sick', + 'side', + 'siege', + 'sight', + 'sign', + 'silent', + 'silk', + 'silly', + 'silver', + 'similar', + 'simple', + 'since', + 'sing', + 'siren', + 'sister', + 'situate', + 'six', + 'size', + 'skate', + 'sketch', + 'ski', + 'skill', + 'skin', + 'skirt', + 'skull', + 'slab', + 'slam', + 'sleep', + 'slender', + 'slice', + 'slide', + 'slight', + 'slim', + 'slogan', + 'slot', + 'slow', + 'slush', + 'small', + 'smart', + 'smile', + 'smoke', + 'smooth', + 'snack', + 'snake', + 'snap', + 'sniff', + 'snow', + 'soap', + 'soccer', + 'social', + 'sock', + 'soda', + 'soft', + 'solar', + 'soldier', + 'solid', + 'solution', + 'solve', + 'someone', + 'song', + 'soon', + 'sorry', + 'sort', + 'soul', + 'sound', + 'soup', + 'source', + 'south', + 'space', + 'spare', + 'spatial', + 'spawn', + 'speak', + 'special', + 'speed', + 'spell', + 'spend', + 'sphere', + 'spice', + 'spider', + 'spike', + 'spin', + 'spirit', + 'split', + 'spoil', + 'sponsor', + 'spoon', + 'sport', + 'spot', + 'spray', + 'spread', + 'spring', + 'spy', + 'square', + 'squeeze', + 'squirrel', + 'stable', + 'stadium', + 'staff', + 'stage', + 'stairs', + 'stamp', + 'stand', + 'start', + 'state', + 'stay', + 'steak', + 'steel', + 'stem', + 'step', + 'stereo', + 'stick', + 'still', + 'sting', + 'stock', + 'stomach', + 'stone', + 'stool', + 'story', + 'stove', + 'strategy', + 'street', + 'strike', + 'strong', + 'struggle', + 'student', + 'stuff', + 'stumble', + 'style', + 'subject', + 'submit', + 'subway', + 'success', + 'such', + 'sudden', + 'suffer', + 'sugar', + 'suggest', + 'suit', + 'summer', + 'sun', + 'sunny', + 'sunset', + 'super', + 'supply', + 'supreme', + 'sure', + 'surface', + 'surge', + 'surprise', + 'surround', + 'survey', + 'suspect', + 'sustain', + 'swallow', + 'swamp', + 'swap', + 'swarm', + 'swear', + 'sweet', + 'swift', + 'swim', + 'swing', + 'switch', + 'sword', + 'symbol', + 'symptom', + 'syrup', + 'system', + 'table', + 'tackle', + 'tag', + 'tail', + 'talent', + 'talk', + 'tank', + 'tape', + 'target', + 'task', + 'taste', + 'tattoo', + 'taxi', + 'teach', + 'team', + 'tell', + 'ten', + 'tenant', + 'tennis', + 'tent', + 'term', + 'test', + 'text', + 'thank', + 'that', + 'theme', + 'then', + 'theory', + 'there', + 'they', + 'thing', + 'this', + 'thought', + 'three', + 'thrive', + 'throw', + 'thumb', + 'thunder', + 'ticket', + 'tide', + 'tiger', + 'tilt', + 'timber', + 'time', + 'tiny', + 'tip', + 'tired', + 'tissue', + 'title', + 'toast', + 'tobacco', + 'today', + 'toddler', + 'toe', + 'together', + 'toilet', + 'token', + 'tomato', + 'tomorrow', + 'tone', + 'tongue', + 'tonight', + 'tool', + 'tooth', + 'top', + 'topic', + 'topple', + 'torch', + 'tornado', + 'tortoise', + 'toss', + 'total', + 'tourist', + 'toward', + 'tower', + 'town', + 'toy', + 'track', + 'trade', + 'traffic', + 'tragic', + 'train', + 'transfer', + 'trap', + 'trash', + 'travel', + 'tray', + 'treat', + 'tree', + 'trend', + 'trial', + 'tribe', + 'trick', + 'trigger', + 'trim', + 'trip', + 'trophy', + 'trouble', + 'truck', + 'true', + 'truly', + 'trumpet', + 'trust', + 'truth', + 'try', + 'tube', + 'tuition', + 'tumble', + 'tuna', + 'tunnel', + 'turkey', + 'turn', + 'turtle', + 'twelve', + 'twenty', + 'twice', + 'twin', + 'twist', + 'two', + 'type', + 'typical', + 'ugly', + 'umbrella', + 'unable', + 'unaware', + 'uncle', + 'uncover', + 'under', + 'undo', + 'unfair', + 'unfold', + 'unhappy', + 'uniform', + 'unique', + 'unit', + 'universe', + 'unknown', + 'unlock', + 'until', + 'unusual', + 'unveil', + 'update', + 'upgrade', + 'uphold', + 'upon', + 'upper', + 'upset', + 'urban', + 'urge', + 'usage', + 'use', + 'used', + 'useful', + 'useless', + 'usual', + 'utility', + 'vacant', + 'vacuum', + 'vague', + 'valid', + 'valley', + 'valve', + 'van', + 'vanish', + 'vapor', + 'various', + 'vast', + 'vault', + 'vehicle', + 'velvet', + 'vendor', + 'venture', + 'venue', + 'verb', + 'verify', + 'version', + 'very', + 'vessel', + 'veteran', + 'viable', + 'vibrant', + 'vicious', + 'victory', + 'video', + 'view', + 'village', + 'vintage', + 'violin', + 'virtual', + 'virus', + 'visa', + 'visit', + 'visual', + 'vital', + 'vivid', + 'vocal', + 'voice', + 'void', + 'volcano', + 'volume', + 'vote', + 'voyage', + 'wage', + 'wagon', + 'wait', + 'walk', + 'wall', + 'walnut', + 'want', + 'warfare', + 'warm', + 'warrior', + 'wash', + 'wasp', + 'waste', + 'water', + 'wave', + 'way', + 'wealth', + 'weapon', + 'wear', + 'weasel', + 'weather', + 'web', + 'wedding', + 'weekend', + 'weird', + 'welcome', + 'west', + 'wet', + 'whale', + 'what', + 'wheat', + 'wheel', + 'when', + 'where', + 'whip', + 'whisper', + 'wide', + 'width', + 'wife', + 'wild', + 'will', + 'win', + 'window', + 'wine', + 'wing', + 'wink', + 'winner', + 'winter', + 'wire', + 'wisdom', + 'wise', + 'wish', + 'witness', + 'wolf', + 'woman', + 'wonder', + 'wood', + 'wool', + 'word', + 'work', + 'world', + 'worry', + 'worth', + 'wrap', + 'wreck', + 'wrestle', + 'wrist', + 'write', + 'wrong', + 'yard', + 'year', + 'yellow', + 'you', + 'young', + 'youth', + 'zebra', + 'zero', + 'zone', + 'zoo' +]) diff --git a/static/js/crypto/aes.js b/static/js/crypto/aes.js new file mode 100644 index 0000000..7395d50 --- /dev/null +++ b/static/js/crypto/aes.js @@ -0,0 +1,1413 @@ +/*! MIT License. Copyright 2015-2018 Richard Moore . See LICENSE.txt. */ +;(function (root) { + 'use strict' + + function checkInt(value) { + return parseInt(value) === value + } + + function checkInts(arrayish) { + if (!checkInt(arrayish.length)) { + return false + } + + for (var i = 0; i < arrayish.length; i++) { + if (!checkInt(arrayish[i]) || arrayish[i] < 0 || arrayish[i] > 255) { + return false + } + } + + return true + } + + function coerceArray(arg, copy) { + // ArrayBuffer view + if (arg.buffer && arg.name === 'Uint8Array') { + if (copy) { + if (arg.slice) { + arg = arg.slice() + } else { + arg = Array.prototype.slice.call(arg) + } + } + + return arg + } + + // It's an array; check it is a valid representation of a byte + if (Array.isArray(arg)) { + if (!checkInts(arg)) { + throw new Error('Array contains invalid value: ' + arg) + } + + return new Uint8Array(arg) + } + + // Something else, but behaves like an array (maybe a Buffer? Arguments?) + if (checkInt(arg.length) && checkInts(arg)) { + return new Uint8Array(arg) + } + throw new Error('unsupported array-like object') + } + + function createArray(length) { + return new Uint8Array(length) + } + + function copyArray( + sourceArray, + targetArray, + targetStart, + sourceStart, + sourceEnd + ) { + if (sourceStart != null || sourceEnd != null) { + if (sourceArray.slice) { + sourceArray = sourceArray.slice(sourceStart, sourceEnd) + } else { + sourceArray = Array.prototype.slice.call( + sourceArray, + sourceStart, + sourceEnd + ) + } + } + targetArray.set(sourceArray, targetStart) + } + + var convertUtf8 = (function () { + function toBytes(text) { + var result = [], + i = 0 + text = encodeURI(text) + while (i < text.length) { + var c = text.charCodeAt(i++) + + // if it is a % sign, encode the following 2 bytes as a hex value + if (c === 37) { + result.push(parseInt(text.substr(i, 2), 16)) + i += 2 + + // otherwise, just the actual byte + } else { + result.push(c) + } + } + + return coerceArray(result) + } + + function fromBytes(bytes) { + var result = [], + i = 0 + + while (i < bytes.length) { + var c = bytes[i] + + if (c < 128) { + result.push(String.fromCharCode(c)) + i++ + } else if (c > 191 && c < 224) { + result.push( + String.fromCharCode(((c & 0x1f) << 6) | (bytes[i + 1] & 0x3f)) + ) + i += 2 + } else { + result.push( + String.fromCharCode( + ((c & 0x0f) << 12) | + ((bytes[i + 1] & 0x3f) << 6) | + (bytes[i + 2] & 0x3f) + ) + ) + i += 3 + } + } + + return result.join('') + } + + return { + toBytes: toBytes, + fromBytes: fromBytes + } + })() + + var convertHex = (function () { + function toBytes(text) { + var result = [] + for (var i = 0; i < text.length; i += 2) { + result.push(parseInt(text.substr(i, 2), 16)) + } + + return result + } + + // http://ixti.net/development/javascript/2011/11/11/base64-encodedecode-of-utf8-in-browser-with-js.html + var Hex = '0123456789abcdef' + + function fromBytes(bytes) { + var result = [] + for (var i = 0; i < bytes.length; i++) { + var v = bytes[i] + result.push(Hex[(v & 0xf0) >> 4] + Hex[v & 0x0f]) + } + return result.join('') + } + + return { + toBytes: toBytes, + fromBytes: fromBytes + } + })() + + // Number of rounds by keysize + var numberOfRounds = {16: 10, 24: 12, 32: 14} + + // Round constant words + var rcon = [ + 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, + 0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, + 0xb3, 0x7d, 0xfa, 0xef, 0xc5, 0x91 + ] + + // S-box and Inverse S-box (S is for Substitution) + var S = [ + 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, + 0xfe, 0xd7, 0xab, 0x76, 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, + 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, 0xb7, 0xfd, 0x93, 0x26, + 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, + 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, + 0xeb, 0x27, 0xb2, 0x75, 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, + 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, 0x53, 0xd1, 0x00, 0xed, + 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, + 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, + 0x50, 0x3c, 0x9f, 0xa8, 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, + 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, 0xcd, 0x0c, 0x13, 0xec, + 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, + 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, + 0xde, 0x5e, 0x0b, 0xdb, 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, + 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, 0xe7, 0xc8, 0x37, 0x6d, + 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, + 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, + 0x4b, 0xbd, 0x8b, 0x8a, 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, + 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, 0xe1, 0xf8, 0x98, 0x11, + 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, + 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, + 0xb0, 0x54, 0xbb, 0x16 + ] + var Si = [ + 0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, + 0x81, 0xf3, 0xd7, 0xfb, 0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, + 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb, 0x54, 0x7b, 0x94, 0x32, + 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e, + 0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, + 0x6d, 0x8b, 0xd1, 0x25, 0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, + 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92, 0x6c, 0x70, 0x48, 0x50, + 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84, + 0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, + 0xb8, 0xb3, 0x45, 0x06, 0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, + 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b, 0x3a, 0x91, 0x11, 0x41, + 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73, + 0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, + 0x1c, 0x75, 0xdf, 0x6e, 0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, + 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b, 0xfc, 0x56, 0x3e, 0x4b, + 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4, + 0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, + 0x27, 0x80, 0xec, 0x5f, 0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, + 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef, 0xa0, 0xe0, 0x3b, 0x4d, + 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61, + 0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, + 0x55, 0x21, 0x0c, 0x7d + ] + + // Transformations for encryption + var T1 = [ + 0xc66363a5, 0xf87c7c84, 0xee777799, 0xf67b7b8d, 0xfff2f20d, 0xd66b6bbd, + 0xde6f6fb1, 0x91c5c554, 0x60303050, 0x02010103, 0xce6767a9, 0x562b2b7d, + 0xe7fefe19, 0xb5d7d762, 0x4dababe6, 0xec76769a, 0x8fcaca45, 0x1f82829d, + 0x89c9c940, 0xfa7d7d87, 0xeffafa15, 0xb25959eb, 0x8e4747c9, 0xfbf0f00b, + 0x41adadec, 0xb3d4d467, 0x5fa2a2fd, 0x45afafea, 0x239c9cbf, 0x53a4a4f7, + 0xe4727296, 0x9bc0c05b, 0x75b7b7c2, 0xe1fdfd1c, 0x3d9393ae, 0x4c26266a, + 0x6c36365a, 0x7e3f3f41, 0xf5f7f702, 0x83cccc4f, 0x6834345c, 0x51a5a5f4, + 0xd1e5e534, 0xf9f1f108, 0xe2717193, 0xabd8d873, 0x62313153, 0x2a15153f, + 0x0804040c, 0x95c7c752, 0x46232365, 0x9dc3c35e, 0x30181828, 0x379696a1, + 0x0a05050f, 0x2f9a9ab5, 0x0e070709, 0x24121236, 0x1b80809b, 0xdfe2e23d, + 0xcdebeb26, 0x4e272769, 0x7fb2b2cd, 0xea75759f, 0x1209091b, 0x1d83839e, + 0x582c2c74, 0x341a1a2e, 0x361b1b2d, 0xdc6e6eb2, 0xb45a5aee, 0x5ba0a0fb, + 0xa45252f6, 0x763b3b4d, 0xb7d6d661, 0x7db3b3ce, 0x5229297b, 0xdde3e33e, + 0x5e2f2f71, 0x13848497, 0xa65353f5, 0xb9d1d168, 0x00000000, 0xc1eded2c, + 0x40202060, 0xe3fcfc1f, 0x79b1b1c8, 0xb65b5bed, 0xd46a6abe, 0x8dcbcb46, + 0x67bebed9, 0x7239394b, 0x944a4ade, 0x984c4cd4, 0xb05858e8, 0x85cfcf4a, + 0xbbd0d06b, 0xc5efef2a, 0x4faaaae5, 0xedfbfb16, 0x864343c5, 0x9a4d4dd7, + 0x66333355, 0x11858594, 0x8a4545cf, 0xe9f9f910, 0x04020206, 0xfe7f7f81, + 0xa05050f0, 0x783c3c44, 0x259f9fba, 0x4ba8a8e3, 0xa25151f3, 0x5da3a3fe, + 0x804040c0, 0x058f8f8a, 0x3f9292ad, 0x219d9dbc, 0x70383848, 0xf1f5f504, + 0x63bcbcdf, 0x77b6b6c1, 0xafdada75, 0x42212163, 0x20101030, 0xe5ffff1a, + 0xfdf3f30e, 0xbfd2d26d, 0x81cdcd4c, 0x180c0c14, 0x26131335, 0xc3ecec2f, + 0xbe5f5fe1, 0x359797a2, 0x884444cc, 0x2e171739, 0x93c4c457, 0x55a7a7f2, + 0xfc7e7e82, 0x7a3d3d47, 0xc86464ac, 0xba5d5de7, 0x3219192b, 0xe6737395, + 0xc06060a0, 0x19818198, 0x9e4f4fd1, 0xa3dcdc7f, 0x44222266, 0x542a2a7e, + 0x3b9090ab, 0x0b888883, 0x8c4646ca, 0xc7eeee29, 0x6bb8b8d3, 0x2814143c, + 0xa7dede79, 0xbc5e5ee2, 0x160b0b1d, 0xaddbdb76, 0xdbe0e03b, 0x64323256, + 0x743a3a4e, 0x140a0a1e, 0x924949db, 0x0c06060a, 0x4824246c, 0xb85c5ce4, + 0x9fc2c25d, 0xbdd3d36e, 0x43acacef, 0xc46262a6, 0x399191a8, 0x319595a4, + 0xd3e4e437, 0xf279798b, 0xd5e7e732, 0x8bc8c843, 0x6e373759, 0xda6d6db7, + 0x018d8d8c, 0xb1d5d564, 0x9c4e4ed2, 0x49a9a9e0, 0xd86c6cb4, 0xac5656fa, + 0xf3f4f407, 0xcfeaea25, 0xca6565af, 0xf47a7a8e, 0x47aeaee9, 0x10080818, + 0x6fbabad5, 0xf0787888, 0x4a25256f, 0x5c2e2e72, 0x381c1c24, 0x57a6a6f1, + 0x73b4b4c7, 0x97c6c651, 0xcbe8e823, 0xa1dddd7c, 0xe874749c, 0x3e1f1f21, + 0x964b4bdd, 0x61bdbddc, 0x0d8b8b86, 0x0f8a8a85, 0xe0707090, 0x7c3e3e42, + 0x71b5b5c4, 0xcc6666aa, 0x904848d8, 0x06030305, 0xf7f6f601, 0x1c0e0e12, + 0xc26161a3, 0x6a35355f, 0xae5757f9, 0x69b9b9d0, 0x17868691, 0x99c1c158, + 0x3a1d1d27, 0x279e9eb9, 0xd9e1e138, 0xebf8f813, 0x2b9898b3, 0x22111133, + 0xd26969bb, 0xa9d9d970, 0x078e8e89, 0x339494a7, 0x2d9b9bb6, 0x3c1e1e22, + 0x15878792, 0xc9e9e920, 0x87cece49, 0xaa5555ff, 0x50282878, 0xa5dfdf7a, + 0x038c8c8f, 0x59a1a1f8, 0x09898980, 0x1a0d0d17, 0x65bfbfda, 0xd7e6e631, + 0x844242c6, 0xd06868b8, 0x824141c3, 0x299999b0, 0x5a2d2d77, 0x1e0f0f11, + 0x7bb0b0cb, 0xa85454fc, 0x6dbbbbd6, 0x2c16163a + ] + var T2 = [ + 0xa5c66363, 0x84f87c7c, 0x99ee7777, 0x8df67b7b, 0x0dfff2f2, 0xbdd66b6b, + 0xb1de6f6f, 0x5491c5c5, 0x50603030, 0x03020101, 0xa9ce6767, 0x7d562b2b, + 0x19e7fefe, 0x62b5d7d7, 0xe64dabab, 0x9aec7676, 0x458fcaca, 0x9d1f8282, + 0x4089c9c9, 0x87fa7d7d, 0x15effafa, 0xebb25959, 0xc98e4747, 0x0bfbf0f0, + 0xec41adad, 0x67b3d4d4, 0xfd5fa2a2, 0xea45afaf, 0xbf239c9c, 0xf753a4a4, + 0x96e47272, 0x5b9bc0c0, 0xc275b7b7, 0x1ce1fdfd, 0xae3d9393, 0x6a4c2626, + 0x5a6c3636, 0x417e3f3f, 0x02f5f7f7, 0x4f83cccc, 0x5c683434, 0xf451a5a5, + 0x34d1e5e5, 0x08f9f1f1, 0x93e27171, 0x73abd8d8, 0x53623131, 0x3f2a1515, + 0x0c080404, 0x5295c7c7, 0x65462323, 0x5e9dc3c3, 0x28301818, 0xa1379696, + 0x0f0a0505, 0xb52f9a9a, 0x090e0707, 0x36241212, 0x9b1b8080, 0x3ddfe2e2, + 0x26cdebeb, 0x694e2727, 0xcd7fb2b2, 0x9fea7575, 0x1b120909, 0x9e1d8383, + 0x74582c2c, 0x2e341a1a, 0x2d361b1b, 0xb2dc6e6e, 0xeeb45a5a, 0xfb5ba0a0, + 0xf6a45252, 0x4d763b3b, 0x61b7d6d6, 0xce7db3b3, 0x7b522929, 0x3edde3e3, + 0x715e2f2f, 0x97138484, 0xf5a65353, 0x68b9d1d1, 0x00000000, 0x2cc1eded, + 0x60402020, 0x1fe3fcfc, 0xc879b1b1, 0xedb65b5b, 0xbed46a6a, 0x468dcbcb, + 0xd967bebe, 0x4b723939, 0xde944a4a, 0xd4984c4c, 0xe8b05858, 0x4a85cfcf, + 0x6bbbd0d0, 0x2ac5efef, 0xe54faaaa, 0x16edfbfb, 0xc5864343, 0xd79a4d4d, + 0x55663333, 0x94118585, 0xcf8a4545, 0x10e9f9f9, 0x06040202, 0x81fe7f7f, + 0xf0a05050, 0x44783c3c, 0xba259f9f, 0xe34ba8a8, 0xf3a25151, 0xfe5da3a3, + 0xc0804040, 0x8a058f8f, 0xad3f9292, 0xbc219d9d, 0x48703838, 0x04f1f5f5, + 0xdf63bcbc, 0xc177b6b6, 0x75afdada, 0x63422121, 0x30201010, 0x1ae5ffff, + 0x0efdf3f3, 0x6dbfd2d2, 0x4c81cdcd, 0x14180c0c, 0x35261313, 0x2fc3ecec, + 0xe1be5f5f, 0xa2359797, 0xcc884444, 0x392e1717, 0x5793c4c4, 0xf255a7a7, + 0x82fc7e7e, 0x477a3d3d, 0xacc86464, 0xe7ba5d5d, 0x2b321919, 0x95e67373, + 0xa0c06060, 0x98198181, 0xd19e4f4f, 0x7fa3dcdc, 0x66442222, 0x7e542a2a, + 0xab3b9090, 0x830b8888, 0xca8c4646, 0x29c7eeee, 0xd36bb8b8, 0x3c281414, + 0x79a7dede, 0xe2bc5e5e, 0x1d160b0b, 0x76addbdb, 0x3bdbe0e0, 0x56643232, + 0x4e743a3a, 0x1e140a0a, 0xdb924949, 0x0a0c0606, 0x6c482424, 0xe4b85c5c, + 0x5d9fc2c2, 0x6ebdd3d3, 0xef43acac, 0xa6c46262, 0xa8399191, 0xa4319595, + 0x37d3e4e4, 0x8bf27979, 0x32d5e7e7, 0x438bc8c8, 0x596e3737, 0xb7da6d6d, + 0x8c018d8d, 0x64b1d5d5, 0xd29c4e4e, 0xe049a9a9, 0xb4d86c6c, 0xfaac5656, + 0x07f3f4f4, 0x25cfeaea, 0xafca6565, 0x8ef47a7a, 0xe947aeae, 0x18100808, + 0xd56fbaba, 0x88f07878, 0x6f4a2525, 0x725c2e2e, 0x24381c1c, 0xf157a6a6, + 0xc773b4b4, 0x5197c6c6, 0x23cbe8e8, 0x7ca1dddd, 0x9ce87474, 0x213e1f1f, + 0xdd964b4b, 0xdc61bdbd, 0x860d8b8b, 0x850f8a8a, 0x90e07070, 0x427c3e3e, + 0xc471b5b5, 0xaacc6666, 0xd8904848, 0x05060303, 0x01f7f6f6, 0x121c0e0e, + 0xa3c26161, 0x5f6a3535, 0xf9ae5757, 0xd069b9b9, 0x91178686, 0x5899c1c1, + 0x273a1d1d, 0xb9279e9e, 0x38d9e1e1, 0x13ebf8f8, 0xb32b9898, 0x33221111, + 0xbbd26969, 0x70a9d9d9, 0x89078e8e, 0xa7339494, 0xb62d9b9b, 0x223c1e1e, + 0x92158787, 0x20c9e9e9, 0x4987cece, 0xffaa5555, 0x78502828, 0x7aa5dfdf, + 0x8f038c8c, 0xf859a1a1, 0x80098989, 0x171a0d0d, 0xda65bfbf, 0x31d7e6e6, + 0xc6844242, 0xb8d06868, 0xc3824141, 0xb0299999, 0x775a2d2d, 0x111e0f0f, + 0xcb7bb0b0, 0xfca85454, 0xd66dbbbb, 0x3a2c1616 + ] + var T3 = [ + 0x63a5c663, 0x7c84f87c, 0x7799ee77, 0x7b8df67b, 0xf20dfff2, 0x6bbdd66b, + 0x6fb1de6f, 0xc55491c5, 0x30506030, 0x01030201, 0x67a9ce67, 0x2b7d562b, + 0xfe19e7fe, 0xd762b5d7, 0xabe64dab, 0x769aec76, 0xca458fca, 0x829d1f82, + 0xc94089c9, 0x7d87fa7d, 0xfa15effa, 0x59ebb259, 0x47c98e47, 0xf00bfbf0, + 0xadec41ad, 0xd467b3d4, 0xa2fd5fa2, 0xafea45af, 0x9cbf239c, 0xa4f753a4, + 0x7296e472, 0xc05b9bc0, 0xb7c275b7, 0xfd1ce1fd, 0x93ae3d93, 0x266a4c26, + 0x365a6c36, 0x3f417e3f, 0xf702f5f7, 0xcc4f83cc, 0x345c6834, 0xa5f451a5, + 0xe534d1e5, 0xf108f9f1, 0x7193e271, 0xd873abd8, 0x31536231, 0x153f2a15, + 0x040c0804, 0xc75295c7, 0x23654623, 0xc35e9dc3, 0x18283018, 0x96a13796, + 0x050f0a05, 0x9ab52f9a, 0x07090e07, 0x12362412, 0x809b1b80, 0xe23ddfe2, + 0xeb26cdeb, 0x27694e27, 0xb2cd7fb2, 0x759fea75, 0x091b1209, 0x839e1d83, + 0x2c74582c, 0x1a2e341a, 0x1b2d361b, 0x6eb2dc6e, 0x5aeeb45a, 0xa0fb5ba0, + 0x52f6a452, 0x3b4d763b, 0xd661b7d6, 0xb3ce7db3, 0x297b5229, 0xe33edde3, + 0x2f715e2f, 0x84971384, 0x53f5a653, 0xd168b9d1, 0x00000000, 0xed2cc1ed, + 0x20604020, 0xfc1fe3fc, 0xb1c879b1, 0x5bedb65b, 0x6abed46a, 0xcb468dcb, + 0xbed967be, 0x394b7239, 0x4ade944a, 0x4cd4984c, 0x58e8b058, 0xcf4a85cf, + 0xd06bbbd0, 0xef2ac5ef, 0xaae54faa, 0xfb16edfb, 0x43c58643, 0x4dd79a4d, + 0x33556633, 0x85941185, 0x45cf8a45, 0xf910e9f9, 0x02060402, 0x7f81fe7f, + 0x50f0a050, 0x3c44783c, 0x9fba259f, 0xa8e34ba8, 0x51f3a251, 0xa3fe5da3, + 0x40c08040, 0x8f8a058f, 0x92ad3f92, 0x9dbc219d, 0x38487038, 0xf504f1f5, + 0xbcdf63bc, 0xb6c177b6, 0xda75afda, 0x21634221, 0x10302010, 0xff1ae5ff, + 0xf30efdf3, 0xd26dbfd2, 0xcd4c81cd, 0x0c14180c, 0x13352613, 0xec2fc3ec, + 0x5fe1be5f, 0x97a23597, 0x44cc8844, 0x17392e17, 0xc45793c4, 0xa7f255a7, + 0x7e82fc7e, 0x3d477a3d, 0x64acc864, 0x5de7ba5d, 0x192b3219, 0x7395e673, + 0x60a0c060, 0x81981981, 0x4fd19e4f, 0xdc7fa3dc, 0x22664422, 0x2a7e542a, + 0x90ab3b90, 0x88830b88, 0x46ca8c46, 0xee29c7ee, 0xb8d36bb8, 0x143c2814, + 0xde79a7de, 0x5ee2bc5e, 0x0b1d160b, 0xdb76addb, 0xe03bdbe0, 0x32566432, + 0x3a4e743a, 0x0a1e140a, 0x49db9249, 0x060a0c06, 0x246c4824, 0x5ce4b85c, + 0xc25d9fc2, 0xd36ebdd3, 0xacef43ac, 0x62a6c462, 0x91a83991, 0x95a43195, + 0xe437d3e4, 0x798bf279, 0xe732d5e7, 0xc8438bc8, 0x37596e37, 0x6db7da6d, + 0x8d8c018d, 0xd564b1d5, 0x4ed29c4e, 0xa9e049a9, 0x6cb4d86c, 0x56faac56, + 0xf407f3f4, 0xea25cfea, 0x65afca65, 0x7a8ef47a, 0xaee947ae, 0x08181008, + 0xbad56fba, 0x7888f078, 0x256f4a25, 0x2e725c2e, 0x1c24381c, 0xa6f157a6, + 0xb4c773b4, 0xc65197c6, 0xe823cbe8, 0xdd7ca1dd, 0x749ce874, 0x1f213e1f, + 0x4bdd964b, 0xbddc61bd, 0x8b860d8b, 0x8a850f8a, 0x7090e070, 0x3e427c3e, + 0xb5c471b5, 0x66aacc66, 0x48d89048, 0x03050603, 0xf601f7f6, 0x0e121c0e, + 0x61a3c261, 0x355f6a35, 0x57f9ae57, 0xb9d069b9, 0x86911786, 0xc15899c1, + 0x1d273a1d, 0x9eb9279e, 0xe138d9e1, 0xf813ebf8, 0x98b32b98, 0x11332211, + 0x69bbd269, 0xd970a9d9, 0x8e89078e, 0x94a73394, 0x9bb62d9b, 0x1e223c1e, + 0x87921587, 0xe920c9e9, 0xce4987ce, 0x55ffaa55, 0x28785028, 0xdf7aa5df, + 0x8c8f038c, 0xa1f859a1, 0x89800989, 0x0d171a0d, 0xbfda65bf, 0xe631d7e6, + 0x42c68442, 0x68b8d068, 0x41c38241, 0x99b02999, 0x2d775a2d, 0x0f111e0f, + 0xb0cb7bb0, 0x54fca854, 0xbbd66dbb, 0x163a2c16 + ] + var T4 = [ + 0x6363a5c6, 0x7c7c84f8, 0x777799ee, 0x7b7b8df6, 0xf2f20dff, 0x6b6bbdd6, + 0x6f6fb1de, 0xc5c55491, 0x30305060, 0x01010302, 0x6767a9ce, 0x2b2b7d56, + 0xfefe19e7, 0xd7d762b5, 0xababe64d, 0x76769aec, 0xcaca458f, 0x82829d1f, + 0xc9c94089, 0x7d7d87fa, 0xfafa15ef, 0x5959ebb2, 0x4747c98e, 0xf0f00bfb, + 0xadadec41, 0xd4d467b3, 0xa2a2fd5f, 0xafafea45, 0x9c9cbf23, 0xa4a4f753, + 0x727296e4, 0xc0c05b9b, 0xb7b7c275, 0xfdfd1ce1, 0x9393ae3d, 0x26266a4c, + 0x36365a6c, 0x3f3f417e, 0xf7f702f5, 0xcccc4f83, 0x34345c68, 0xa5a5f451, + 0xe5e534d1, 0xf1f108f9, 0x717193e2, 0xd8d873ab, 0x31315362, 0x15153f2a, + 0x04040c08, 0xc7c75295, 0x23236546, 0xc3c35e9d, 0x18182830, 0x9696a137, + 0x05050f0a, 0x9a9ab52f, 0x0707090e, 0x12123624, 0x80809b1b, 0xe2e23ddf, + 0xebeb26cd, 0x2727694e, 0xb2b2cd7f, 0x75759fea, 0x09091b12, 0x83839e1d, + 0x2c2c7458, 0x1a1a2e34, 0x1b1b2d36, 0x6e6eb2dc, 0x5a5aeeb4, 0xa0a0fb5b, + 0x5252f6a4, 0x3b3b4d76, 0xd6d661b7, 0xb3b3ce7d, 0x29297b52, 0xe3e33edd, + 0x2f2f715e, 0x84849713, 0x5353f5a6, 0xd1d168b9, 0x00000000, 0xeded2cc1, + 0x20206040, 0xfcfc1fe3, 0xb1b1c879, 0x5b5bedb6, 0x6a6abed4, 0xcbcb468d, + 0xbebed967, 0x39394b72, 0x4a4ade94, 0x4c4cd498, 0x5858e8b0, 0xcfcf4a85, + 0xd0d06bbb, 0xefef2ac5, 0xaaaae54f, 0xfbfb16ed, 0x4343c586, 0x4d4dd79a, + 0x33335566, 0x85859411, 0x4545cf8a, 0xf9f910e9, 0x02020604, 0x7f7f81fe, + 0x5050f0a0, 0x3c3c4478, 0x9f9fba25, 0xa8a8e34b, 0x5151f3a2, 0xa3a3fe5d, + 0x4040c080, 0x8f8f8a05, 0x9292ad3f, 0x9d9dbc21, 0x38384870, 0xf5f504f1, + 0xbcbcdf63, 0xb6b6c177, 0xdada75af, 0x21216342, 0x10103020, 0xffff1ae5, + 0xf3f30efd, 0xd2d26dbf, 0xcdcd4c81, 0x0c0c1418, 0x13133526, 0xecec2fc3, + 0x5f5fe1be, 0x9797a235, 0x4444cc88, 0x1717392e, 0xc4c45793, 0xa7a7f255, + 0x7e7e82fc, 0x3d3d477a, 0x6464acc8, 0x5d5de7ba, 0x19192b32, 0x737395e6, + 0x6060a0c0, 0x81819819, 0x4f4fd19e, 0xdcdc7fa3, 0x22226644, 0x2a2a7e54, + 0x9090ab3b, 0x8888830b, 0x4646ca8c, 0xeeee29c7, 0xb8b8d36b, 0x14143c28, + 0xdede79a7, 0x5e5ee2bc, 0x0b0b1d16, 0xdbdb76ad, 0xe0e03bdb, 0x32325664, + 0x3a3a4e74, 0x0a0a1e14, 0x4949db92, 0x06060a0c, 0x24246c48, 0x5c5ce4b8, + 0xc2c25d9f, 0xd3d36ebd, 0xacacef43, 0x6262a6c4, 0x9191a839, 0x9595a431, + 0xe4e437d3, 0x79798bf2, 0xe7e732d5, 0xc8c8438b, 0x3737596e, 0x6d6db7da, + 0x8d8d8c01, 0xd5d564b1, 0x4e4ed29c, 0xa9a9e049, 0x6c6cb4d8, 0x5656faac, + 0xf4f407f3, 0xeaea25cf, 0x6565afca, 0x7a7a8ef4, 0xaeaee947, 0x08081810, + 0xbabad56f, 0x787888f0, 0x25256f4a, 0x2e2e725c, 0x1c1c2438, 0xa6a6f157, + 0xb4b4c773, 0xc6c65197, 0xe8e823cb, 0xdddd7ca1, 0x74749ce8, 0x1f1f213e, + 0x4b4bdd96, 0xbdbddc61, 0x8b8b860d, 0x8a8a850f, 0x707090e0, 0x3e3e427c, + 0xb5b5c471, 0x6666aacc, 0x4848d890, 0x03030506, 0xf6f601f7, 0x0e0e121c, + 0x6161a3c2, 0x35355f6a, 0x5757f9ae, 0xb9b9d069, 0x86869117, 0xc1c15899, + 0x1d1d273a, 0x9e9eb927, 0xe1e138d9, 0xf8f813eb, 0x9898b32b, 0x11113322, + 0x6969bbd2, 0xd9d970a9, 0x8e8e8907, 0x9494a733, 0x9b9bb62d, 0x1e1e223c, + 0x87879215, 0xe9e920c9, 0xcece4987, 0x5555ffaa, 0x28287850, 0xdfdf7aa5, + 0x8c8c8f03, 0xa1a1f859, 0x89898009, 0x0d0d171a, 0xbfbfda65, 0xe6e631d7, + 0x4242c684, 0x6868b8d0, 0x4141c382, 0x9999b029, 0x2d2d775a, 0x0f0f111e, + 0xb0b0cb7b, 0x5454fca8, 0xbbbbd66d, 0x16163a2c + ] + + // Transformations for decryption + var T5 = [ + 0x51f4a750, 0x7e416553, 0x1a17a4c3, 0x3a275e96, 0x3bab6bcb, 0x1f9d45f1, + 0xacfa58ab, 0x4be30393, 0x2030fa55, 0xad766df6, 0x88cc7691, 0xf5024c25, + 0x4fe5d7fc, 0xc52acbd7, 0x26354480, 0xb562a38f, 0xdeb15a49, 0x25ba1b67, + 0x45ea0e98, 0x5dfec0e1, 0xc32f7502, 0x814cf012, 0x8d4697a3, 0x6bd3f9c6, + 0x038f5fe7, 0x15929c95, 0xbf6d7aeb, 0x955259da, 0xd4be832d, 0x587421d3, + 0x49e06929, 0x8ec9c844, 0x75c2896a, 0xf48e7978, 0x99583e6b, 0x27b971dd, + 0xbee14fb6, 0xf088ad17, 0xc920ac66, 0x7dce3ab4, 0x63df4a18, 0xe51a3182, + 0x97513360, 0x62537f45, 0xb16477e0, 0xbb6bae84, 0xfe81a01c, 0xf9082b94, + 0x70486858, 0x8f45fd19, 0x94de6c87, 0x527bf8b7, 0xab73d323, 0x724b02e2, + 0xe31f8f57, 0x6655ab2a, 0xb2eb2807, 0x2fb5c203, 0x86c57b9a, 0xd33708a5, + 0x302887f2, 0x23bfa5b2, 0x02036aba, 0xed16825c, 0x8acf1c2b, 0xa779b492, + 0xf307f2f0, 0x4e69e2a1, 0x65daf4cd, 0x0605bed5, 0xd134621f, 0xc4a6fe8a, + 0x342e539d, 0xa2f355a0, 0x058ae132, 0xa4f6eb75, 0x0b83ec39, 0x4060efaa, + 0x5e719f06, 0xbd6e1051, 0x3e218af9, 0x96dd063d, 0xdd3e05ae, 0x4de6bd46, + 0x91548db5, 0x71c45d05, 0x0406d46f, 0x605015ff, 0x1998fb24, 0xd6bde997, + 0x894043cc, 0x67d99e77, 0xb0e842bd, 0x07898b88, 0xe7195b38, 0x79c8eedb, + 0xa17c0a47, 0x7c420fe9, 0xf8841ec9, 0x00000000, 0x09808683, 0x322bed48, + 0x1e1170ac, 0x6c5a724e, 0xfd0efffb, 0x0f853856, 0x3daed51e, 0x362d3927, + 0x0a0fd964, 0x685ca621, 0x9b5b54d1, 0x24362e3a, 0x0c0a67b1, 0x9357e70f, + 0xb4ee96d2, 0x1b9b919e, 0x80c0c54f, 0x61dc20a2, 0x5a774b69, 0x1c121a16, + 0xe293ba0a, 0xc0a02ae5, 0x3c22e043, 0x121b171d, 0x0e090d0b, 0xf28bc7ad, + 0x2db6a8b9, 0x141ea9c8, 0x57f11985, 0xaf75074c, 0xee99ddbb, 0xa37f60fd, + 0xf701269f, 0x5c72f5bc, 0x44663bc5, 0x5bfb7e34, 0x8b432976, 0xcb23c6dc, + 0xb6edfc68, 0xb8e4f163, 0xd731dcca, 0x42638510, 0x13972240, 0x84c61120, + 0x854a247d, 0xd2bb3df8, 0xaef93211, 0xc729a16d, 0x1d9e2f4b, 0xdcb230f3, + 0x0d8652ec, 0x77c1e3d0, 0x2bb3166c, 0xa970b999, 0x119448fa, 0x47e96422, + 0xa8fc8cc4, 0xa0f03f1a, 0x567d2cd8, 0x223390ef, 0x87494ec7, 0xd938d1c1, + 0x8ccaa2fe, 0x98d40b36, 0xa6f581cf, 0xa57ade28, 0xdab78e26, 0x3fadbfa4, + 0x2c3a9de4, 0x5078920d, 0x6a5fcc9b, 0x547e4662, 0xf68d13c2, 0x90d8b8e8, + 0x2e39f75e, 0x82c3aff5, 0x9f5d80be, 0x69d0937c, 0x6fd52da9, 0xcf2512b3, + 0xc8ac993b, 0x10187da7, 0xe89c636e, 0xdb3bbb7b, 0xcd267809, 0x6e5918f4, + 0xec9ab701, 0x834f9aa8, 0xe6956e65, 0xaaffe67e, 0x21bccf08, 0xef15e8e6, + 0xbae79bd9, 0x4a6f36ce, 0xea9f09d4, 0x29b07cd6, 0x31a4b2af, 0x2a3f2331, + 0xc6a59430, 0x35a266c0, 0x744ebc37, 0xfc82caa6, 0xe090d0b0, 0x33a7d815, + 0xf104984a, 0x41ecdaf7, 0x7fcd500e, 0x1791f62f, 0x764dd68d, 0x43efb04d, + 0xccaa4d54, 0xe49604df, 0x9ed1b5e3, 0x4c6a881b, 0xc12c1fb8, 0x4665517f, + 0x9d5eea04, 0x018c355d, 0xfa877473, 0xfb0b412e, 0xb3671d5a, 0x92dbd252, + 0xe9105633, 0x6dd64713, 0x9ad7618c, 0x37a10c7a, 0x59f8148e, 0xeb133c89, + 0xcea927ee, 0xb761c935, 0xe11ce5ed, 0x7a47b13c, 0x9cd2df59, 0x55f2733f, + 0x1814ce79, 0x73c737bf, 0x53f7cdea, 0x5ffdaa5b, 0xdf3d6f14, 0x7844db86, + 0xcaaff381, 0xb968c43e, 0x3824342c, 0xc2a3405f, 0x161dc372, 0xbce2250c, + 0x283c498b, 0xff0d9541, 0x39a80171, 0x080cb3de, 0xd8b4e49c, 0x6456c190, + 0x7bcb8461, 0xd532b670, 0x486c5c74, 0xd0b85742 + ] + var T6 = [ + 0x5051f4a7, 0x537e4165, 0xc31a17a4, 0x963a275e, 0xcb3bab6b, 0xf11f9d45, + 0xabacfa58, 0x934be303, 0x552030fa, 0xf6ad766d, 0x9188cc76, 0x25f5024c, + 0xfc4fe5d7, 0xd7c52acb, 0x80263544, 0x8fb562a3, 0x49deb15a, 0x6725ba1b, + 0x9845ea0e, 0xe15dfec0, 0x02c32f75, 0x12814cf0, 0xa38d4697, 0xc66bd3f9, + 0xe7038f5f, 0x9515929c, 0xebbf6d7a, 0xda955259, 0x2dd4be83, 0xd3587421, + 0x2949e069, 0x448ec9c8, 0x6a75c289, 0x78f48e79, 0x6b99583e, 0xdd27b971, + 0xb6bee14f, 0x17f088ad, 0x66c920ac, 0xb47dce3a, 0x1863df4a, 0x82e51a31, + 0x60975133, 0x4562537f, 0xe0b16477, 0x84bb6bae, 0x1cfe81a0, 0x94f9082b, + 0x58704868, 0x198f45fd, 0x8794de6c, 0xb7527bf8, 0x23ab73d3, 0xe2724b02, + 0x57e31f8f, 0x2a6655ab, 0x07b2eb28, 0x032fb5c2, 0x9a86c57b, 0xa5d33708, + 0xf2302887, 0xb223bfa5, 0xba02036a, 0x5ced1682, 0x2b8acf1c, 0x92a779b4, + 0xf0f307f2, 0xa14e69e2, 0xcd65daf4, 0xd50605be, 0x1fd13462, 0x8ac4a6fe, + 0x9d342e53, 0xa0a2f355, 0x32058ae1, 0x75a4f6eb, 0x390b83ec, 0xaa4060ef, + 0x065e719f, 0x51bd6e10, 0xf93e218a, 0x3d96dd06, 0xaedd3e05, 0x464de6bd, + 0xb591548d, 0x0571c45d, 0x6f0406d4, 0xff605015, 0x241998fb, 0x97d6bde9, + 0xcc894043, 0x7767d99e, 0xbdb0e842, 0x8807898b, 0x38e7195b, 0xdb79c8ee, + 0x47a17c0a, 0xe97c420f, 0xc9f8841e, 0x00000000, 0x83098086, 0x48322bed, + 0xac1e1170, 0x4e6c5a72, 0xfbfd0eff, 0x560f8538, 0x1e3daed5, 0x27362d39, + 0x640a0fd9, 0x21685ca6, 0xd19b5b54, 0x3a24362e, 0xb10c0a67, 0x0f9357e7, + 0xd2b4ee96, 0x9e1b9b91, 0x4f80c0c5, 0xa261dc20, 0x695a774b, 0x161c121a, + 0x0ae293ba, 0xe5c0a02a, 0x433c22e0, 0x1d121b17, 0x0b0e090d, 0xadf28bc7, + 0xb92db6a8, 0xc8141ea9, 0x8557f119, 0x4caf7507, 0xbbee99dd, 0xfda37f60, + 0x9ff70126, 0xbc5c72f5, 0xc544663b, 0x345bfb7e, 0x768b4329, 0xdccb23c6, + 0x68b6edfc, 0x63b8e4f1, 0xcad731dc, 0x10426385, 0x40139722, 0x2084c611, + 0x7d854a24, 0xf8d2bb3d, 0x11aef932, 0x6dc729a1, 0x4b1d9e2f, 0xf3dcb230, + 0xec0d8652, 0xd077c1e3, 0x6c2bb316, 0x99a970b9, 0xfa119448, 0x2247e964, + 0xc4a8fc8c, 0x1aa0f03f, 0xd8567d2c, 0xef223390, 0xc787494e, 0xc1d938d1, + 0xfe8ccaa2, 0x3698d40b, 0xcfa6f581, 0x28a57ade, 0x26dab78e, 0xa43fadbf, + 0xe42c3a9d, 0x0d507892, 0x9b6a5fcc, 0x62547e46, 0xc2f68d13, 0xe890d8b8, + 0x5e2e39f7, 0xf582c3af, 0xbe9f5d80, 0x7c69d093, 0xa96fd52d, 0xb3cf2512, + 0x3bc8ac99, 0xa710187d, 0x6ee89c63, 0x7bdb3bbb, 0x09cd2678, 0xf46e5918, + 0x01ec9ab7, 0xa8834f9a, 0x65e6956e, 0x7eaaffe6, 0x0821bccf, 0xe6ef15e8, + 0xd9bae79b, 0xce4a6f36, 0xd4ea9f09, 0xd629b07c, 0xaf31a4b2, 0x312a3f23, + 0x30c6a594, 0xc035a266, 0x37744ebc, 0xa6fc82ca, 0xb0e090d0, 0x1533a7d8, + 0x4af10498, 0xf741ecda, 0x0e7fcd50, 0x2f1791f6, 0x8d764dd6, 0x4d43efb0, + 0x54ccaa4d, 0xdfe49604, 0xe39ed1b5, 0x1b4c6a88, 0xb8c12c1f, 0x7f466551, + 0x049d5eea, 0x5d018c35, 0x73fa8774, 0x2efb0b41, 0x5ab3671d, 0x5292dbd2, + 0x33e91056, 0x136dd647, 0x8c9ad761, 0x7a37a10c, 0x8e59f814, 0x89eb133c, + 0xeecea927, 0x35b761c9, 0xede11ce5, 0x3c7a47b1, 0x599cd2df, 0x3f55f273, + 0x791814ce, 0xbf73c737, 0xea53f7cd, 0x5b5ffdaa, 0x14df3d6f, 0x867844db, + 0x81caaff3, 0x3eb968c4, 0x2c382434, 0x5fc2a340, 0x72161dc3, 0x0cbce225, + 0x8b283c49, 0x41ff0d95, 0x7139a801, 0xde080cb3, 0x9cd8b4e4, 0x906456c1, + 0x617bcb84, 0x70d532b6, 0x74486c5c, 0x42d0b857 + ] + var T7 = [ + 0xa75051f4, 0x65537e41, 0xa4c31a17, 0x5e963a27, 0x6bcb3bab, 0x45f11f9d, + 0x58abacfa, 0x03934be3, 0xfa552030, 0x6df6ad76, 0x769188cc, 0x4c25f502, + 0xd7fc4fe5, 0xcbd7c52a, 0x44802635, 0xa38fb562, 0x5a49deb1, 0x1b6725ba, + 0x0e9845ea, 0xc0e15dfe, 0x7502c32f, 0xf012814c, 0x97a38d46, 0xf9c66bd3, + 0x5fe7038f, 0x9c951592, 0x7aebbf6d, 0x59da9552, 0x832dd4be, 0x21d35874, + 0x692949e0, 0xc8448ec9, 0x896a75c2, 0x7978f48e, 0x3e6b9958, 0x71dd27b9, + 0x4fb6bee1, 0xad17f088, 0xac66c920, 0x3ab47dce, 0x4a1863df, 0x3182e51a, + 0x33609751, 0x7f456253, 0x77e0b164, 0xae84bb6b, 0xa01cfe81, 0x2b94f908, + 0x68587048, 0xfd198f45, 0x6c8794de, 0xf8b7527b, 0xd323ab73, 0x02e2724b, + 0x8f57e31f, 0xab2a6655, 0x2807b2eb, 0xc2032fb5, 0x7b9a86c5, 0x08a5d337, + 0x87f23028, 0xa5b223bf, 0x6aba0203, 0x825ced16, 0x1c2b8acf, 0xb492a779, + 0xf2f0f307, 0xe2a14e69, 0xf4cd65da, 0xbed50605, 0x621fd134, 0xfe8ac4a6, + 0x539d342e, 0x55a0a2f3, 0xe132058a, 0xeb75a4f6, 0xec390b83, 0xefaa4060, + 0x9f065e71, 0x1051bd6e, 0x8af93e21, 0x063d96dd, 0x05aedd3e, 0xbd464de6, + 0x8db59154, 0x5d0571c4, 0xd46f0406, 0x15ff6050, 0xfb241998, 0xe997d6bd, + 0x43cc8940, 0x9e7767d9, 0x42bdb0e8, 0x8b880789, 0x5b38e719, 0xeedb79c8, + 0x0a47a17c, 0x0fe97c42, 0x1ec9f884, 0x00000000, 0x86830980, 0xed48322b, + 0x70ac1e11, 0x724e6c5a, 0xfffbfd0e, 0x38560f85, 0xd51e3dae, 0x3927362d, + 0xd9640a0f, 0xa621685c, 0x54d19b5b, 0x2e3a2436, 0x67b10c0a, 0xe70f9357, + 0x96d2b4ee, 0x919e1b9b, 0xc54f80c0, 0x20a261dc, 0x4b695a77, 0x1a161c12, + 0xba0ae293, 0x2ae5c0a0, 0xe0433c22, 0x171d121b, 0x0d0b0e09, 0xc7adf28b, + 0xa8b92db6, 0xa9c8141e, 0x198557f1, 0x074caf75, 0xddbbee99, 0x60fda37f, + 0x269ff701, 0xf5bc5c72, 0x3bc54466, 0x7e345bfb, 0x29768b43, 0xc6dccb23, + 0xfc68b6ed, 0xf163b8e4, 0xdccad731, 0x85104263, 0x22401397, 0x112084c6, + 0x247d854a, 0x3df8d2bb, 0x3211aef9, 0xa16dc729, 0x2f4b1d9e, 0x30f3dcb2, + 0x52ec0d86, 0xe3d077c1, 0x166c2bb3, 0xb999a970, 0x48fa1194, 0x642247e9, + 0x8cc4a8fc, 0x3f1aa0f0, 0x2cd8567d, 0x90ef2233, 0x4ec78749, 0xd1c1d938, + 0xa2fe8cca, 0x0b3698d4, 0x81cfa6f5, 0xde28a57a, 0x8e26dab7, 0xbfa43fad, + 0x9de42c3a, 0x920d5078, 0xcc9b6a5f, 0x4662547e, 0x13c2f68d, 0xb8e890d8, + 0xf75e2e39, 0xaff582c3, 0x80be9f5d, 0x937c69d0, 0x2da96fd5, 0x12b3cf25, + 0x993bc8ac, 0x7da71018, 0x636ee89c, 0xbb7bdb3b, 0x7809cd26, 0x18f46e59, + 0xb701ec9a, 0x9aa8834f, 0x6e65e695, 0xe67eaaff, 0xcf0821bc, 0xe8e6ef15, + 0x9bd9bae7, 0x36ce4a6f, 0x09d4ea9f, 0x7cd629b0, 0xb2af31a4, 0x23312a3f, + 0x9430c6a5, 0x66c035a2, 0xbc37744e, 0xcaa6fc82, 0xd0b0e090, 0xd81533a7, + 0x984af104, 0xdaf741ec, 0x500e7fcd, 0xf62f1791, 0xd68d764d, 0xb04d43ef, + 0x4d54ccaa, 0x04dfe496, 0xb5e39ed1, 0x881b4c6a, 0x1fb8c12c, 0x517f4665, + 0xea049d5e, 0x355d018c, 0x7473fa87, 0x412efb0b, 0x1d5ab367, 0xd25292db, + 0x5633e910, 0x47136dd6, 0x618c9ad7, 0x0c7a37a1, 0x148e59f8, 0x3c89eb13, + 0x27eecea9, 0xc935b761, 0xe5ede11c, 0xb13c7a47, 0xdf599cd2, 0x733f55f2, + 0xce791814, 0x37bf73c7, 0xcdea53f7, 0xaa5b5ffd, 0x6f14df3d, 0xdb867844, + 0xf381caaf, 0xc43eb968, 0x342c3824, 0x405fc2a3, 0xc372161d, 0x250cbce2, + 0x498b283c, 0x9541ff0d, 0x017139a8, 0xb3de080c, 0xe49cd8b4, 0xc1906456, + 0x84617bcb, 0xb670d532, 0x5c74486c, 0x5742d0b8 + ] + var T8 = [ + 0xf4a75051, 0x4165537e, 0x17a4c31a, 0x275e963a, 0xab6bcb3b, 0x9d45f11f, + 0xfa58abac, 0xe303934b, 0x30fa5520, 0x766df6ad, 0xcc769188, 0x024c25f5, + 0xe5d7fc4f, 0x2acbd7c5, 0x35448026, 0x62a38fb5, 0xb15a49de, 0xba1b6725, + 0xea0e9845, 0xfec0e15d, 0x2f7502c3, 0x4cf01281, 0x4697a38d, 0xd3f9c66b, + 0x8f5fe703, 0x929c9515, 0x6d7aebbf, 0x5259da95, 0xbe832dd4, 0x7421d358, + 0xe0692949, 0xc9c8448e, 0xc2896a75, 0x8e7978f4, 0x583e6b99, 0xb971dd27, + 0xe14fb6be, 0x88ad17f0, 0x20ac66c9, 0xce3ab47d, 0xdf4a1863, 0x1a3182e5, + 0x51336097, 0x537f4562, 0x6477e0b1, 0x6bae84bb, 0x81a01cfe, 0x082b94f9, + 0x48685870, 0x45fd198f, 0xde6c8794, 0x7bf8b752, 0x73d323ab, 0x4b02e272, + 0x1f8f57e3, 0x55ab2a66, 0xeb2807b2, 0xb5c2032f, 0xc57b9a86, 0x3708a5d3, + 0x2887f230, 0xbfa5b223, 0x036aba02, 0x16825ced, 0xcf1c2b8a, 0x79b492a7, + 0x07f2f0f3, 0x69e2a14e, 0xdaf4cd65, 0x05bed506, 0x34621fd1, 0xa6fe8ac4, + 0x2e539d34, 0xf355a0a2, 0x8ae13205, 0xf6eb75a4, 0x83ec390b, 0x60efaa40, + 0x719f065e, 0x6e1051bd, 0x218af93e, 0xdd063d96, 0x3e05aedd, 0xe6bd464d, + 0x548db591, 0xc45d0571, 0x06d46f04, 0x5015ff60, 0x98fb2419, 0xbde997d6, + 0x4043cc89, 0xd99e7767, 0xe842bdb0, 0x898b8807, 0x195b38e7, 0xc8eedb79, + 0x7c0a47a1, 0x420fe97c, 0x841ec9f8, 0x00000000, 0x80868309, 0x2bed4832, + 0x1170ac1e, 0x5a724e6c, 0x0efffbfd, 0x8538560f, 0xaed51e3d, 0x2d392736, + 0x0fd9640a, 0x5ca62168, 0x5b54d19b, 0x362e3a24, 0x0a67b10c, 0x57e70f93, + 0xee96d2b4, 0x9b919e1b, 0xc0c54f80, 0xdc20a261, 0x774b695a, 0x121a161c, + 0x93ba0ae2, 0xa02ae5c0, 0x22e0433c, 0x1b171d12, 0x090d0b0e, 0x8bc7adf2, + 0xb6a8b92d, 0x1ea9c814, 0xf1198557, 0x75074caf, 0x99ddbbee, 0x7f60fda3, + 0x01269ff7, 0x72f5bc5c, 0x663bc544, 0xfb7e345b, 0x4329768b, 0x23c6dccb, + 0xedfc68b6, 0xe4f163b8, 0x31dccad7, 0x63851042, 0x97224013, 0xc6112084, + 0x4a247d85, 0xbb3df8d2, 0xf93211ae, 0x29a16dc7, 0x9e2f4b1d, 0xb230f3dc, + 0x8652ec0d, 0xc1e3d077, 0xb3166c2b, 0x70b999a9, 0x9448fa11, 0xe9642247, + 0xfc8cc4a8, 0xf03f1aa0, 0x7d2cd856, 0x3390ef22, 0x494ec787, 0x38d1c1d9, + 0xcaa2fe8c, 0xd40b3698, 0xf581cfa6, 0x7ade28a5, 0xb78e26da, 0xadbfa43f, + 0x3a9de42c, 0x78920d50, 0x5fcc9b6a, 0x7e466254, 0x8d13c2f6, 0xd8b8e890, + 0x39f75e2e, 0xc3aff582, 0x5d80be9f, 0xd0937c69, 0xd52da96f, 0x2512b3cf, + 0xac993bc8, 0x187da710, 0x9c636ee8, 0x3bbb7bdb, 0x267809cd, 0x5918f46e, + 0x9ab701ec, 0x4f9aa883, 0x956e65e6, 0xffe67eaa, 0xbccf0821, 0x15e8e6ef, + 0xe79bd9ba, 0x6f36ce4a, 0x9f09d4ea, 0xb07cd629, 0xa4b2af31, 0x3f23312a, + 0xa59430c6, 0xa266c035, 0x4ebc3774, 0x82caa6fc, 0x90d0b0e0, 0xa7d81533, + 0x04984af1, 0xecdaf741, 0xcd500e7f, 0x91f62f17, 0x4dd68d76, 0xefb04d43, + 0xaa4d54cc, 0x9604dfe4, 0xd1b5e39e, 0x6a881b4c, 0x2c1fb8c1, 0x65517f46, + 0x5eea049d, 0x8c355d01, 0x877473fa, 0x0b412efb, 0x671d5ab3, 0xdbd25292, + 0x105633e9, 0xd647136d, 0xd7618c9a, 0xa10c7a37, 0xf8148e59, 0x133c89eb, + 0xa927eece, 0x61c935b7, 0x1ce5ede1, 0x47b13c7a, 0xd2df599c, 0xf2733f55, + 0x14ce7918, 0xc737bf73, 0xf7cdea53, 0xfdaa5b5f, 0x3d6f14df, 0x44db8678, + 0xaff381ca, 0x68c43eb9, 0x24342c38, 0xa3405fc2, 0x1dc37216, 0xe2250cbc, + 0x3c498b28, 0x0d9541ff, 0xa8017139, 0x0cb3de08, 0xb4e49cd8, 0x56c19064, + 0xcb84617b, 0x32b670d5, 0x6c5c7448, 0xb85742d0 + ] + + // Transformations for decryption key expansion + var U1 = [ + 0x00000000, 0x0e090d0b, 0x1c121a16, 0x121b171d, 0x3824342c, 0x362d3927, + 0x24362e3a, 0x2a3f2331, 0x70486858, 0x7e416553, 0x6c5a724e, 0x62537f45, + 0x486c5c74, 0x4665517f, 0x547e4662, 0x5a774b69, 0xe090d0b0, 0xee99ddbb, + 0xfc82caa6, 0xf28bc7ad, 0xd8b4e49c, 0xd6bde997, 0xc4a6fe8a, 0xcaaff381, + 0x90d8b8e8, 0x9ed1b5e3, 0x8ccaa2fe, 0x82c3aff5, 0xa8fc8cc4, 0xa6f581cf, + 0xb4ee96d2, 0xbae79bd9, 0xdb3bbb7b, 0xd532b670, 0xc729a16d, 0xc920ac66, + 0xe31f8f57, 0xed16825c, 0xff0d9541, 0xf104984a, 0xab73d323, 0xa57ade28, + 0xb761c935, 0xb968c43e, 0x9357e70f, 0x9d5eea04, 0x8f45fd19, 0x814cf012, + 0x3bab6bcb, 0x35a266c0, 0x27b971dd, 0x29b07cd6, 0x038f5fe7, 0x0d8652ec, + 0x1f9d45f1, 0x119448fa, 0x4be30393, 0x45ea0e98, 0x57f11985, 0x59f8148e, + 0x73c737bf, 0x7dce3ab4, 0x6fd52da9, 0x61dc20a2, 0xad766df6, 0xa37f60fd, + 0xb16477e0, 0xbf6d7aeb, 0x955259da, 0x9b5b54d1, 0x894043cc, 0x87494ec7, + 0xdd3e05ae, 0xd33708a5, 0xc12c1fb8, 0xcf2512b3, 0xe51a3182, 0xeb133c89, + 0xf9082b94, 0xf701269f, 0x4de6bd46, 0x43efb04d, 0x51f4a750, 0x5ffdaa5b, + 0x75c2896a, 0x7bcb8461, 0x69d0937c, 0x67d99e77, 0x3daed51e, 0x33a7d815, + 0x21bccf08, 0x2fb5c203, 0x058ae132, 0x0b83ec39, 0x1998fb24, 0x1791f62f, + 0x764dd68d, 0x7844db86, 0x6a5fcc9b, 0x6456c190, 0x4e69e2a1, 0x4060efaa, + 0x527bf8b7, 0x5c72f5bc, 0x0605bed5, 0x080cb3de, 0x1a17a4c3, 0x141ea9c8, + 0x3e218af9, 0x302887f2, 0x223390ef, 0x2c3a9de4, 0x96dd063d, 0x98d40b36, + 0x8acf1c2b, 0x84c61120, 0xaef93211, 0xa0f03f1a, 0xb2eb2807, 0xbce2250c, + 0xe6956e65, 0xe89c636e, 0xfa877473, 0xf48e7978, 0xdeb15a49, 0xd0b85742, + 0xc2a3405f, 0xccaa4d54, 0x41ecdaf7, 0x4fe5d7fc, 0x5dfec0e1, 0x53f7cdea, + 0x79c8eedb, 0x77c1e3d0, 0x65daf4cd, 0x6bd3f9c6, 0x31a4b2af, 0x3fadbfa4, + 0x2db6a8b9, 0x23bfa5b2, 0x09808683, 0x07898b88, 0x15929c95, 0x1b9b919e, + 0xa17c0a47, 0xaf75074c, 0xbd6e1051, 0xb3671d5a, 0x99583e6b, 0x97513360, + 0x854a247d, 0x8b432976, 0xd134621f, 0xdf3d6f14, 0xcd267809, 0xc32f7502, + 0xe9105633, 0xe7195b38, 0xf5024c25, 0xfb0b412e, 0x9ad7618c, 0x94de6c87, + 0x86c57b9a, 0x88cc7691, 0xa2f355a0, 0xacfa58ab, 0xbee14fb6, 0xb0e842bd, + 0xea9f09d4, 0xe49604df, 0xf68d13c2, 0xf8841ec9, 0xd2bb3df8, 0xdcb230f3, + 0xcea927ee, 0xc0a02ae5, 0x7a47b13c, 0x744ebc37, 0x6655ab2a, 0x685ca621, + 0x42638510, 0x4c6a881b, 0x5e719f06, 0x5078920d, 0x0a0fd964, 0x0406d46f, + 0x161dc372, 0x1814ce79, 0x322bed48, 0x3c22e043, 0x2e39f75e, 0x2030fa55, + 0xec9ab701, 0xe293ba0a, 0xf088ad17, 0xfe81a01c, 0xd4be832d, 0xdab78e26, + 0xc8ac993b, 0xc6a59430, 0x9cd2df59, 0x92dbd252, 0x80c0c54f, 0x8ec9c844, + 0xa4f6eb75, 0xaaffe67e, 0xb8e4f163, 0xb6edfc68, 0x0c0a67b1, 0x02036aba, + 0x10187da7, 0x1e1170ac, 0x342e539d, 0x3a275e96, 0x283c498b, 0x26354480, + 0x7c420fe9, 0x724b02e2, 0x605015ff, 0x6e5918f4, 0x44663bc5, 0x4a6f36ce, + 0x587421d3, 0x567d2cd8, 0x37a10c7a, 0x39a80171, 0x2bb3166c, 0x25ba1b67, + 0x0f853856, 0x018c355d, 0x13972240, 0x1d9e2f4b, 0x47e96422, 0x49e06929, + 0x5bfb7e34, 0x55f2733f, 0x7fcd500e, 0x71c45d05, 0x63df4a18, 0x6dd64713, + 0xd731dcca, 0xd938d1c1, 0xcb23c6dc, 0xc52acbd7, 0xef15e8e6, 0xe11ce5ed, + 0xf307f2f0, 0xfd0efffb, 0xa779b492, 0xa970b999, 0xbb6bae84, 0xb562a38f, + 0x9f5d80be, 0x91548db5, 0x834f9aa8, 0x8d4697a3 + ] + var U2 = [ + 0x00000000, 0x0b0e090d, 0x161c121a, 0x1d121b17, 0x2c382434, 0x27362d39, + 0x3a24362e, 0x312a3f23, 0x58704868, 0x537e4165, 0x4e6c5a72, 0x4562537f, + 0x74486c5c, 0x7f466551, 0x62547e46, 0x695a774b, 0xb0e090d0, 0xbbee99dd, + 0xa6fc82ca, 0xadf28bc7, 0x9cd8b4e4, 0x97d6bde9, 0x8ac4a6fe, 0x81caaff3, + 0xe890d8b8, 0xe39ed1b5, 0xfe8ccaa2, 0xf582c3af, 0xc4a8fc8c, 0xcfa6f581, + 0xd2b4ee96, 0xd9bae79b, 0x7bdb3bbb, 0x70d532b6, 0x6dc729a1, 0x66c920ac, + 0x57e31f8f, 0x5ced1682, 0x41ff0d95, 0x4af10498, 0x23ab73d3, 0x28a57ade, + 0x35b761c9, 0x3eb968c4, 0x0f9357e7, 0x049d5eea, 0x198f45fd, 0x12814cf0, + 0xcb3bab6b, 0xc035a266, 0xdd27b971, 0xd629b07c, 0xe7038f5f, 0xec0d8652, + 0xf11f9d45, 0xfa119448, 0x934be303, 0x9845ea0e, 0x8557f119, 0x8e59f814, + 0xbf73c737, 0xb47dce3a, 0xa96fd52d, 0xa261dc20, 0xf6ad766d, 0xfda37f60, + 0xe0b16477, 0xebbf6d7a, 0xda955259, 0xd19b5b54, 0xcc894043, 0xc787494e, + 0xaedd3e05, 0xa5d33708, 0xb8c12c1f, 0xb3cf2512, 0x82e51a31, 0x89eb133c, + 0x94f9082b, 0x9ff70126, 0x464de6bd, 0x4d43efb0, 0x5051f4a7, 0x5b5ffdaa, + 0x6a75c289, 0x617bcb84, 0x7c69d093, 0x7767d99e, 0x1e3daed5, 0x1533a7d8, + 0x0821bccf, 0x032fb5c2, 0x32058ae1, 0x390b83ec, 0x241998fb, 0x2f1791f6, + 0x8d764dd6, 0x867844db, 0x9b6a5fcc, 0x906456c1, 0xa14e69e2, 0xaa4060ef, + 0xb7527bf8, 0xbc5c72f5, 0xd50605be, 0xde080cb3, 0xc31a17a4, 0xc8141ea9, + 0xf93e218a, 0xf2302887, 0xef223390, 0xe42c3a9d, 0x3d96dd06, 0x3698d40b, + 0x2b8acf1c, 0x2084c611, 0x11aef932, 0x1aa0f03f, 0x07b2eb28, 0x0cbce225, + 0x65e6956e, 0x6ee89c63, 0x73fa8774, 0x78f48e79, 0x49deb15a, 0x42d0b857, + 0x5fc2a340, 0x54ccaa4d, 0xf741ecda, 0xfc4fe5d7, 0xe15dfec0, 0xea53f7cd, + 0xdb79c8ee, 0xd077c1e3, 0xcd65daf4, 0xc66bd3f9, 0xaf31a4b2, 0xa43fadbf, + 0xb92db6a8, 0xb223bfa5, 0x83098086, 0x8807898b, 0x9515929c, 0x9e1b9b91, + 0x47a17c0a, 0x4caf7507, 0x51bd6e10, 0x5ab3671d, 0x6b99583e, 0x60975133, + 0x7d854a24, 0x768b4329, 0x1fd13462, 0x14df3d6f, 0x09cd2678, 0x02c32f75, + 0x33e91056, 0x38e7195b, 0x25f5024c, 0x2efb0b41, 0x8c9ad761, 0x8794de6c, + 0x9a86c57b, 0x9188cc76, 0xa0a2f355, 0xabacfa58, 0xb6bee14f, 0xbdb0e842, + 0xd4ea9f09, 0xdfe49604, 0xc2f68d13, 0xc9f8841e, 0xf8d2bb3d, 0xf3dcb230, + 0xeecea927, 0xe5c0a02a, 0x3c7a47b1, 0x37744ebc, 0x2a6655ab, 0x21685ca6, + 0x10426385, 0x1b4c6a88, 0x065e719f, 0x0d507892, 0x640a0fd9, 0x6f0406d4, + 0x72161dc3, 0x791814ce, 0x48322bed, 0x433c22e0, 0x5e2e39f7, 0x552030fa, + 0x01ec9ab7, 0x0ae293ba, 0x17f088ad, 0x1cfe81a0, 0x2dd4be83, 0x26dab78e, + 0x3bc8ac99, 0x30c6a594, 0x599cd2df, 0x5292dbd2, 0x4f80c0c5, 0x448ec9c8, + 0x75a4f6eb, 0x7eaaffe6, 0x63b8e4f1, 0x68b6edfc, 0xb10c0a67, 0xba02036a, + 0xa710187d, 0xac1e1170, 0x9d342e53, 0x963a275e, 0x8b283c49, 0x80263544, + 0xe97c420f, 0xe2724b02, 0xff605015, 0xf46e5918, 0xc544663b, 0xce4a6f36, + 0xd3587421, 0xd8567d2c, 0x7a37a10c, 0x7139a801, 0x6c2bb316, 0x6725ba1b, + 0x560f8538, 0x5d018c35, 0x40139722, 0x4b1d9e2f, 0x2247e964, 0x2949e069, + 0x345bfb7e, 0x3f55f273, 0x0e7fcd50, 0x0571c45d, 0x1863df4a, 0x136dd647, + 0xcad731dc, 0xc1d938d1, 0xdccb23c6, 0xd7c52acb, 0xe6ef15e8, 0xede11ce5, + 0xf0f307f2, 0xfbfd0eff, 0x92a779b4, 0x99a970b9, 0x84bb6bae, 0x8fb562a3, + 0xbe9f5d80, 0xb591548d, 0xa8834f9a, 0xa38d4697 + ] + var U3 = [ + 0x00000000, 0x0d0b0e09, 0x1a161c12, 0x171d121b, 0x342c3824, 0x3927362d, + 0x2e3a2436, 0x23312a3f, 0x68587048, 0x65537e41, 0x724e6c5a, 0x7f456253, + 0x5c74486c, 0x517f4665, 0x4662547e, 0x4b695a77, 0xd0b0e090, 0xddbbee99, + 0xcaa6fc82, 0xc7adf28b, 0xe49cd8b4, 0xe997d6bd, 0xfe8ac4a6, 0xf381caaf, + 0xb8e890d8, 0xb5e39ed1, 0xa2fe8cca, 0xaff582c3, 0x8cc4a8fc, 0x81cfa6f5, + 0x96d2b4ee, 0x9bd9bae7, 0xbb7bdb3b, 0xb670d532, 0xa16dc729, 0xac66c920, + 0x8f57e31f, 0x825ced16, 0x9541ff0d, 0x984af104, 0xd323ab73, 0xde28a57a, + 0xc935b761, 0xc43eb968, 0xe70f9357, 0xea049d5e, 0xfd198f45, 0xf012814c, + 0x6bcb3bab, 0x66c035a2, 0x71dd27b9, 0x7cd629b0, 0x5fe7038f, 0x52ec0d86, + 0x45f11f9d, 0x48fa1194, 0x03934be3, 0x0e9845ea, 0x198557f1, 0x148e59f8, + 0x37bf73c7, 0x3ab47dce, 0x2da96fd5, 0x20a261dc, 0x6df6ad76, 0x60fda37f, + 0x77e0b164, 0x7aebbf6d, 0x59da9552, 0x54d19b5b, 0x43cc8940, 0x4ec78749, + 0x05aedd3e, 0x08a5d337, 0x1fb8c12c, 0x12b3cf25, 0x3182e51a, 0x3c89eb13, + 0x2b94f908, 0x269ff701, 0xbd464de6, 0xb04d43ef, 0xa75051f4, 0xaa5b5ffd, + 0x896a75c2, 0x84617bcb, 0x937c69d0, 0x9e7767d9, 0xd51e3dae, 0xd81533a7, + 0xcf0821bc, 0xc2032fb5, 0xe132058a, 0xec390b83, 0xfb241998, 0xf62f1791, + 0xd68d764d, 0xdb867844, 0xcc9b6a5f, 0xc1906456, 0xe2a14e69, 0xefaa4060, + 0xf8b7527b, 0xf5bc5c72, 0xbed50605, 0xb3de080c, 0xa4c31a17, 0xa9c8141e, + 0x8af93e21, 0x87f23028, 0x90ef2233, 0x9de42c3a, 0x063d96dd, 0x0b3698d4, + 0x1c2b8acf, 0x112084c6, 0x3211aef9, 0x3f1aa0f0, 0x2807b2eb, 0x250cbce2, + 0x6e65e695, 0x636ee89c, 0x7473fa87, 0x7978f48e, 0x5a49deb1, 0x5742d0b8, + 0x405fc2a3, 0x4d54ccaa, 0xdaf741ec, 0xd7fc4fe5, 0xc0e15dfe, 0xcdea53f7, + 0xeedb79c8, 0xe3d077c1, 0xf4cd65da, 0xf9c66bd3, 0xb2af31a4, 0xbfa43fad, + 0xa8b92db6, 0xa5b223bf, 0x86830980, 0x8b880789, 0x9c951592, 0x919e1b9b, + 0x0a47a17c, 0x074caf75, 0x1051bd6e, 0x1d5ab367, 0x3e6b9958, 0x33609751, + 0x247d854a, 0x29768b43, 0x621fd134, 0x6f14df3d, 0x7809cd26, 0x7502c32f, + 0x5633e910, 0x5b38e719, 0x4c25f502, 0x412efb0b, 0x618c9ad7, 0x6c8794de, + 0x7b9a86c5, 0x769188cc, 0x55a0a2f3, 0x58abacfa, 0x4fb6bee1, 0x42bdb0e8, + 0x09d4ea9f, 0x04dfe496, 0x13c2f68d, 0x1ec9f884, 0x3df8d2bb, 0x30f3dcb2, + 0x27eecea9, 0x2ae5c0a0, 0xb13c7a47, 0xbc37744e, 0xab2a6655, 0xa621685c, + 0x85104263, 0x881b4c6a, 0x9f065e71, 0x920d5078, 0xd9640a0f, 0xd46f0406, + 0xc372161d, 0xce791814, 0xed48322b, 0xe0433c22, 0xf75e2e39, 0xfa552030, + 0xb701ec9a, 0xba0ae293, 0xad17f088, 0xa01cfe81, 0x832dd4be, 0x8e26dab7, + 0x993bc8ac, 0x9430c6a5, 0xdf599cd2, 0xd25292db, 0xc54f80c0, 0xc8448ec9, + 0xeb75a4f6, 0xe67eaaff, 0xf163b8e4, 0xfc68b6ed, 0x67b10c0a, 0x6aba0203, + 0x7da71018, 0x70ac1e11, 0x539d342e, 0x5e963a27, 0x498b283c, 0x44802635, + 0x0fe97c42, 0x02e2724b, 0x15ff6050, 0x18f46e59, 0x3bc54466, 0x36ce4a6f, + 0x21d35874, 0x2cd8567d, 0x0c7a37a1, 0x017139a8, 0x166c2bb3, 0x1b6725ba, + 0x38560f85, 0x355d018c, 0x22401397, 0x2f4b1d9e, 0x642247e9, 0x692949e0, + 0x7e345bfb, 0x733f55f2, 0x500e7fcd, 0x5d0571c4, 0x4a1863df, 0x47136dd6, + 0xdccad731, 0xd1c1d938, 0xc6dccb23, 0xcbd7c52a, 0xe8e6ef15, 0xe5ede11c, + 0xf2f0f307, 0xfffbfd0e, 0xb492a779, 0xb999a970, 0xae84bb6b, 0xa38fb562, + 0x80be9f5d, 0x8db59154, 0x9aa8834f, 0x97a38d46 + ] + var U4 = [ + 0x00000000, 0x090d0b0e, 0x121a161c, 0x1b171d12, 0x24342c38, 0x2d392736, + 0x362e3a24, 0x3f23312a, 0x48685870, 0x4165537e, 0x5a724e6c, 0x537f4562, + 0x6c5c7448, 0x65517f46, 0x7e466254, 0x774b695a, 0x90d0b0e0, 0x99ddbbee, + 0x82caa6fc, 0x8bc7adf2, 0xb4e49cd8, 0xbde997d6, 0xa6fe8ac4, 0xaff381ca, + 0xd8b8e890, 0xd1b5e39e, 0xcaa2fe8c, 0xc3aff582, 0xfc8cc4a8, 0xf581cfa6, + 0xee96d2b4, 0xe79bd9ba, 0x3bbb7bdb, 0x32b670d5, 0x29a16dc7, 0x20ac66c9, + 0x1f8f57e3, 0x16825ced, 0x0d9541ff, 0x04984af1, 0x73d323ab, 0x7ade28a5, + 0x61c935b7, 0x68c43eb9, 0x57e70f93, 0x5eea049d, 0x45fd198f, 0x4cf01281, + 0xab6bcb3b, 0xa266c035, 0xb971dd27, 0xb07cd629, 0x8f5fe703, 0x8652ec0d, + 0x9d45f11f, 0x9448fa11, 0xe303934b, 0xea0e9845, 0xf1198557, 0xf8148e59, + 0xc737bf73, 0xce3ab47d, 0xd52da96f, 0xdc20a261, 0x766df6ad, 0x7f60fda3, + 0x6477e0b1, 0x6d7aebbf, 0x5259da95, 0x5b54d19b, 0x4043cc89, 0x494ec787, + 0x3e05aedd, 0x3708a5d3, 0x2c1fb8c1, 0x2512b3cf, 0x1a3182e5, 0x133c89eb, + 0x082b94f9, 0x01269ff7, 0xe6bd464d, 0xefb04d43, 0xf4a75051, 0xfdaa5b5f, + 0xc2896a75, 0xcb84617b, 0xd0937c69, 0xd99e7767, 0xaed51e3d, 0xa7d81533, + 0xbccf0821, 0xb5c2032f, 0x8ae13205, 0x83ec390b, 0x98fb2419, 0x91f62f17, + 0x4dd68d76, 0x44db8678, 0x5fcc9b6a, 0x56c19064, 0x69e2a14e, 0x60efaa40, + 0x7bf8b752, 0x72f5bc5c, 0x05bed506, 0x0cb3de08, 0x17a4c31a, 0x1ea9c814, + 0x218af93e, 0x2887f230, 0x3390ef22, 0x3a9de42c, 0xdd063d96, 0xd40b3698, + 0xcf1c2b8a, 0xc6112084, 0xf93211ae, 0xf03f1aa0, 0xeb2807b2, 0xe2250cbc, + 0x956e65e6, 0x9c636ee8, 0x877473fa, 0x8e7978f4, 0xb15a49de, 0xb85742d0, + 0xa3405fc2, 0xaa4d54cc, 0xecdaf741, 0xe5d7fc4f, 0xfec0e15d, 0xf7cdea53, + 0xc8eedb79, 0xc1e3d077, 0xdaf4cd65, 0xd3f9c66b, 0xa4b2af31, 0xadbfa43f, + 0xb6a8b92d, 0xbfa5b223, 0x80868309, 0x898b8807, 0x929c9515, 0x9b919e1b, + 0x7c0a47a1, 0x75074caf, 0x6e1051bd, 0x671d5ab3, 0x583e6b99, 0x51336097, + 0x4a247d85, 0x4329768b, 0x34621fd1, 0x3d6f14df, 0x267809cd, 0x2f7502c3, + 0x105633e9, 0x195b38e7, 0x024c25f5, 0x0b412efb, 0xd7618c9a, 0xde6c8794, + 0xc57b9a86, 0xcc769188, 0xf355a0a2, 0xfa58abac, 0xe14fb6be, 0xe842bdb0, + 0x9f09d4ea, 0x9604dfe4, 0x8d13c2f6, 0x841ec9f8, 0xbb3df8d2, 0xb230f3dc, + 0xa927eece, 0xa02ae5c0, 0x47b13c7a, 0x4ebc3774, 0x55ab2a66, 0x5ca62168, + 0x63851042, 0x6a881b4c, 0x719f065e, 0x78920d50, 0x0fd9640a, 0x06d46f04, + 0x1dc37216, 0x14ce7918, 0x2bed4832, 0x22e0433c, 0x39f75e2e, 0x30fa5520, + 0x9ab701ec, 0x93ba0ae2, 0x88ad17f0, 0x81a01cfe, 0xbe832dd4, 0xb78e26da, + 0xac993bc8, 0xa59430c6, 0xd2df599c, 0xdbd25292, 0xc0c54f80, 0xc9c8448e, + 0xf6eb75a4, 0xffe67eaa, 0xe4f163b8, 0xedfc68b6, 0x0a67b10c, 0x036aba02, + 0x187da710, 0x1170ac1e, 0x2e539d34, 0x275e963a, 0x3c498b28, 0x35448026, + 0x420fe97c, 0x4b02e272, 0x5015ff60, 0x5918f46e, 0x663bc544, 0x6f36ce4a, + 0x7421d358, 0x7d2cd856, 0xa10c7a37, 0xa8017139, 0xb3166c2b, 0xba1b6725, + 0x8538560f, 0x8c355d01, 0x97224013, 0x9e2f4b1d, 0xe9642247, 0xe0692949, + 0xfb7e345b, 0xf2733f55, 0xcd500e7f, 0xc45d0571, 0xdf4a1863, 0xd647136d, + 0x31dccad7, 0x38d1c1d9, 0x23c6dccb, 0x2acbd7c5, 0x15e8e6ef, 0x1ce5ede1, + 0x07f2f0f3, 0x0efffbfd, 0x79b492a7, 0x70b999a9, 0x6bae84bb, 0x62a38fb5, + 0x5d80be9f, 0x548db591, 0x4f9aa883, 0x4697a38d + ] + + function convertToInt32(bytes) { + var result = [] + for (var i = 0; i < bytes.length; i += 4) { + result.push( + (bytes[i] << 24) | + (bytes[i + 1] << 16) | + (bytes[i + 2] << 8) | + bytes[i + 3] + ) + } + return result + } + + var AES = function (key) { + if (!(this instanceof AES)) { + throw Error('AES must be instanitated with `new`') + } + + Object.defineProperty(this, 'key', { + value: coerceArray(key, true) + }) + + this._prepare() + } + + AES.prototype._prepare = function () { + var rounds = numberOfRounds[this.key.length] + if (rounds == null) { + throw new Error('invalid key size (must be 16, 24 or 32 bytes)') + } + + // encryption round keys + this._Ke = [] + + // decryption round keys + this._Kd = [] + + for (var i = 0; i <= rounds; i++) { + this._Ke.push([0, 0, 0, 0]) + this._Kd.push([0, 0, 0, 0]) + } + + var roundKeyCount = (rounds + 1) * 4 + var KC = this.key.length / 4 + + // convert the key into ints + var tk = convertToInt32(this.key) + + // copy values into round key arrays + var index + for (var i = 0; i < KC; i++) { + index = i >> 2 + this._Ke[index][i % 4] = tk[i] + this._Kd[rounds - index][i % 4] = tk[i] + } + + // key expansion (fips-197 section 5.2) + var rconpointer = 0 + var t = KC, + tt + while (t < roundKeyCount) { + tt = tk[KC - 1] + tk[0] ^= + (S[(tt >> 16) & 0xff] << 24) ^ + (S[(tt >> 8) & 0xff] << 16) ^ + (S[tt & 0xff] << 8) ^ + S[(tt >> 24) & 0xff] ^ + (rcon[rconpointer] << 24) + rconpointer += 1 + + // key expansion (for non-256 bit) + if (KC != 8) { + for (var i = 1; i < KC; i++) { + tk[i] ^= tk[i - 1] + } + + // key expansion for 256-bit keys is "slightly different" (fips-197) + } else { + for (var i = 1; i < KC / 2; i++) { + tk[i] ^= tk[i - 1] + } + tt = tk[KC / 2 - 1] + + tk[KC / 2] ^= + S[tt & 0xff] ^ + (S[(tt >> 8) & 0xff] << 8) ^ + (S[(tt >> 16) & 0xff] << 16) ^ + (S[(tt >> 24) & 0xff] << 24) + + for (var i = KC / 2 + 1; i < KC; i++) { + tk[i] ^= tk[i - 1] + } + } + + // copy values into round key arrays + var i = 0, + r, + c + while (i < KC && t < roundKeyCount) { + r = t >> 2 + c = t % 4 + this._Ke[r][c] = tk[i] + this._Kd[rounds - r][c] = tk[i++] + t++ + } + } + + // inverse-cipher-ify the decryption round key (fips-197 section 5.3) + for (var r = 1; r < rounds; r++) { + for (var c = 0; c < 4; c++) { + tt = this._Kd[r][c] + this._Kd[r][c] = + U1[(tt >> 24) & 0xff] ^ + U2[(tt >> 16) & 0xff] ^ + U3[(tt >> 8) & 0xff] ^ + U4[tt & 0xff] + } + } + } + + AES.prototype.encrypt = function (plaintext) { + if (plaintext.length != 16) { + throw new Error('invalid plaintext size (must be 16 bytes)') + } + + var rounds = this._Ke.length - 1 + var a = [0, 0, 0, 0] + + // convert plaintext to (ints ^ key) + var t = convertToInt32(plaintext) + for (var i = 0; i < 4; i++) { + t[i] ^= this._Ke[0][i] + } + + // apply round transforms + for (var r = 1; r < rounds; r++) { + for (var i = 0; i < 4; i++) { + a[i] = + T1[(t[i] >> 24) & 0xff] ^ + T2[(t[(i + 1) % 4] >> 16) & 0xff] ^ + T3[(t[(i + 2) % 4] >> 8) & 0xff] ^ + T4[t[(i + 3) % 4] & 0xff] ^ + this._Ke[r][i] + } + t = a.slice() + } + + // the last round is special + var result = createArray(16), + tt + for (var i = 0; i < 4; i++) { + tt = this._Ke[rounds][i] + result[4 * i] = (S[(t[i] >> 24) & 0xff] ^ (tt >> 24)) & 0xff + result[4 * i + 1] = (S[(t[(i + 1) % 4] >> 16) & 0xff] ^ (tt >> 16)) & 0xff + result[4 * i + 2] = (S[(t[(i + 2) % 4] >> 8) & 0xff] ^ (tt >> 8)) & 0xff + result[4 * i + 3] = (S[t[(i + 3) % 4] & 0xff] ^ tt) & 0xff + } + + return result + } + + AES.prototype.decrypt = function (ciphertext) { + if (ciphertext.length != 16) { + throw new Error('invalid ciphertext size (must be 16 bytes)') + } + + var rounds = this._Kd.length - 1 + var a = [0, 0, 0, 0] + + // convert plaintext to (ints ^ key) + var t = convertToInt32(ciphertext) + for (var i = 0; i < 4; i++) { + t[i] ^= this._Kd[0][i] + } + + // apply round transforms + for (var r = 1; r < rounds; r++) { + for (var i = 0; i < 4; i++) { + a[i] = + T5[(t[i] >> 24) & 0xff] ^ + T6[(t[(i + 3) % 4] >> 16) & 0xff] ^ + T7[(t[(i + 2) % 4] >> 8) & 0xff] ^ + T8[t[(i + 1) % 4] & 0xff] ^ + this._Kd[r][i] + } + t = a.slice() + } + + // the last round is special + var result = createArray(16), + tt + for (var i = 0; i < 4; i++) { + tt = this._Kd[rounds][i] + result[4 * i] = (Si[(t[i] >> 24) & 0xff] ^ (tt >> 24)) & 0xff + result[4 * i + 1] = + (Si[(t[(i + 3) % 4] >> 16) & 0xff] ^ (tt >> 16)) & 0xff + result[4 * i + 2] = (Si[(t[(i + 2) % 4] >> 8) & 0xff] ^ (tt >> 8)) & 0xff + result[4 * i + 3] = (Si[t[(i + 1) % 4] & 0xff] ^ tt) & 0xff + } + + return result + } + + /** + * Mode Of Operation - Electonic Codebook (ECB) + */ + var ModeOfOperationECB = function (key) { + if (!(this instanceof ModeOfOperationECB)) { + throw Error('AES must be instanitated with `new`') + } + + this.description = 'Electronic Code Block' + this.name = 'ecb' + + this._aes = new AES(key) + } + + ModeOfOperationECB.prototype.encrypt = function (plaintext) { + plaintext = coerceArray(plaintext) + + if (plaintext.length % 16 !== 0) { + throw new Error('invalid plaintext size (must be multiple of 16 bytes)') + } + + var ciphertext = createArray(plaintext.length) + var block = createArray(16) + + for (var i = 0; i < plaintext.length; i += 16) { + copyArray(plaintext, block, 0, i, i + 16) + block = this._aes.encrypt(block) + copyArray(block, ciphertext, i) + } + + return ciphertext + } + + ModeOfOperationECB.prototype.decrypt = function (ciphertext) { + ciphertext = coerceArray(ciphertext) + + if (ciphertext.length % 16 !== 0) { + throw new Error('invalid ciphertext size (must be multiple of 16 bytes)') + } + + var plaintext = createArray(ciphertext.length) + var block = createArray(16) + + for (var i = 0; i < ciphertext.length; i += 16) { + copyArray(ciphertext, block, 0, i, i + 16) + block = this._aes.decrypt(block) + copyArray(block, plaintext, i) + } + + return plaintext + } + + /** + * Mode Of Operation - Cipher Block Chaining (CBC) + */ + var ModeOfOperationCBC = function (key, iv) { + if (!(this instanceof ModeOfOperationCBC)) { + throw Error('AES must be instanitated with `new`') + } + + this.description = 'Cipher Block Chaining' + this.name = 'cbc' + + if (!iv) { + iv = createArray(16) + } else if (iv.length != 16) { + throw new Error('invalid initialation vector size (must be 16 bytes)') + } + + this._lastCipherblock = coerceArray(iv, true) + + this._aes = new AES(key) + } + + ModeOfOperationCBC.prototype.encrypt = function (plaintext) { + plaintext = coerceArray(plaintext) + + if (plaintext.length % 16 !== 0) { + throw new Error('invalid plaintext size (must be multiple of 16 bytes)') + } + + var ciphertext = createArray(plaintext.length) + var block = createArray(16) + + for (var i = 0; i < plaintext.length; i += 16) { + copyArray(plaintext, block, 0, i, i + 16) + + for (var j = 0; j < 16; j++) { + block[j] ^= this._lastCipherblock[j] + } + + this._lastCipherblock = this._aes.encrypt(block) + copyArray(this._lastCipherblock, ciphertext, i) + } + + return ciphertext + } + + ModeOfOperationCBC.prototype.decrypt = function (ciphertext) { + ciphertext = coerceArray(ciphertext) + + if (ciphertext.length % 16 !== 0) { + throw new Error('invalid ciphertext size (must be multiple of 16 bytes)') + } + + var plaintext = createArray(ciphertext.length) + var block = createArray(16) + + for (var i = 0; i < ciphertext.length; i += 16) { + copyArray(ciphertext, block, 0, i, i + 16) + block = this._aes.decrypt(block) + + for (var j = 0; j < 16; j++) { + plaintext[i + j] = block[j] ^ this._lastCipherblock[j] + } + + copyArray(ciphertext, this._lastCipherblock, 0, i, i + 16) + } + + return plaintext + } + + /** + * Mode Of Operation - Cipher Feedback (CFB) + */ + var ModeOfOperationCFB = function (key, iv, segmentSize) { + if (!(this instanceof ModeOfOperationCFB)) { + throw Error('AES must be instanitated with `new`') + } + + this.description = 'Cipher Feedback' + this.name = 'cfb' + + if (!iv) { + iv = createArray(16) + } else if (iv.length != 16) { + throw new Error('invalid initialation vector size (must be 16 size)') + } + + if (!segmentSize) { + segmentSize = 1 + } + + this.segmentSize = segmentSize + + this._shiftRegister = coerceArray(iv, true) + + this._aes = new AES(key) + } + + ModeOfOperationCFB.prototype.encrypt = function (plaintext) { + if (plaintext.length % this.segmentSize != 0) { + throw new Error('invalid plaintext size (must be segmentSize bytes)') + } + + var encrypted = coerceArray(plaintext, true) + + var xorSegment + for (var i = 0; i < encrypted.length; i += this.segmentSize) { + xorSegment = this._aes.encrypt(this._shiftRegister) + for (var j = 0; j < this.segmentSize; j++) { + encrypted[i + j] ^= xorSegment[j] + } + + // Shift the register + copyArray(this._shiftRegister, this._shiftRegister, 0, this.segmentSize) + copyArray( + encrypted, + this._shiftRegister, + 16 - this.segmentSize, + i, + i + this.segmentSize + ) + } + + return encrypted + } + + ModeOfOperationCFB.prototype.decrypt = function (ciphertext) { + if (ciphertext.length % this.segmentSize != 0) { + throw new Error('invalid ciphertext size (must be segmentSize bytes)') + } + + var plaintext = coerceArray(ciphertext, true) + + var xorSegment + for (var i = 0; i < plaintext.length; i += this.segmentSize) { + xorSegment = this._aes.encrypt(this._shiftRegister) + + for (var j = 0; j < this.segmentSize; j++) { + plaintext[i + j] ^= xorSegment[j] + } + + // Shift the register + copyArray(this._shiftRegister, this._shiftRegister, 0, this.segmentSize) + copyArray( + ciphertext, + this._shiftRegister, + 16 - this.segmentSize, + i, + i + this.segmentSize + ) + } + + return plaintext + } + + /** + * Mode Of Operation - Output Feedback (OFB) + */ + var ModeOfOperationOFB = function (key, iv) { + if (!(this instanceof ModeOfOperationOFB)) { + throw Error('AES must be instanitated with `new`') + } + + this.description = 'Output Feedback' + this.name = 'ofb' + + if (!iv) { + iv = createArray(16) + } else if (iv.length != 16) { + throw new Error('invalid initialation vector size (must be 16 bytes)') + } + + this._lastPrecipher = coerceArray(iv, true) + this._lastPrecipherIndex = 16 + + this._aes = new AES(key) + } + + ModeOfOperationOFB.prototype.encrypt = function (plaintext) { + var encrypted = coerceArray(plaintext, true) + + for (var i = 0; i < encrypted.length; i++) { + if (this._lastPrecipherIndex === 16) { + this._lastPrecipher = this._aes.encrypt(this._lastPrecipher) + this._lastPrecipherIndex = 0 + } + encrypted[i] ^= this._lastPrecipher[this._lastPrecipherIndex++] + } + + return encrypted + } + + // Decryption is symetric + ModeOfOperationOFB.prototype.decrypt = ModeOfOperationOFB.prototype.encrypt + + /** + * Counter object for CTR common mode of operation + */ + var Counter = function (initialValue) { + if (!(this instanceof Counter)) { + throw Error('Counter must be instanitated with `new`') + } + + // We allow 0, but anything false-ish uses the default 1 + if (initialValue !== 0 && !initialValue) { + initialValue = 1 + } + + if (typeof initialValue === 'number') { + this._counter = createArray(16) + this.setValue(initialValue) + } else { + this.setBytes(initialValue) + } + } + + Counter.prototype.setValue = function (value) { + if (typeof value !== 'number' || parseInt(value) != value) { + throw new Error('invalid counter value (must be an integer)') + } + + // We cannot safely handle numbers beyond the safe range for integers + if (value > Number.MAX_SAFE_INTEGER) { + throw new Error('integer value out of safe range') + } + + for (var index = 15; index >= 0; --index) { + this._counter[index] = value % 256 + value = parseInt(value / 256) + } + } + + Counter.prototype.setBytes = function (bytes) { + bytes = coerceArray(bytes, true) + + if (bytes.length != 16) { + throw new Error('invalid counter bytes size (must be 16 bytes)') + } + + this._counter = bytes + } + + Counter.prototype.increment = function () { + for (var i = 15; i >= 0; i--) { + if (this._counter[i] === 255) { + this._counter[i] = 0 + } else { + this._counter[i]++ + break + } + } + } + + /** + * Mode Of Operation - Counter (CTR) + */ + var ModeOfOperationCTR = function (key, counter) { + if (!(this instanceof ModeOfOperationCTR)) { + throw Error('AES must be instanitated with `new`') + } + + this.description = 'Counter' + this.name = 'ctr' + + if (!(counter instanceof Counter)) { + counter = new Counter(counter) + } + + this._counter = counter + + this._remainingCounter = null + this._remainingCounterIndex = 16 + + this._aes = new AES(key) + } + + ModeOfOperationCTR.prototype.encrypt = function (plaintext) { + var encrypted = coerceArray(plaintext, true) + + for (var i = 0; i < encrypted.length; i++) { + if (this._remainingCounterIndex === 16) { + this._remainingCounter = this._aes.encrypt(this._counter._counter) + this._remainingCounterIndex = 0 + this._counter.increment() + } + encrypted[i] ^= this._remainingCounter[this._remainingCounterIndex++] + } + + return encrypted + } + + // Decryption is symetric + ModeOfOperationCTR.prototype.decrypt = ModeOfOperationCTR.prototype.encrypt + + /////////////////////// + // Padding + + // See:https://tools.ietf.org/html/rfc2315 + function pkcs7pad(data) { + data = coerceArray(data, true) + var padder = 16 - (data.length % 16) + var result = createArray(data.length + padder) + copyArray(data, result) + for (var i = data.length; i < result.length; i++) { + result[i] = padder + } + return result + } + + function pkcs7strip(data) { + data = coerceArray(data, true) + if (data.length < 16) { + throw new Error('PKCS#7 invalid length') + } + + var padder = data[data.length - 1] + if (padder > 16) { + throw new Error('PKCS#7 padding byte out of range') + } + + var length = data.length - padder + for (var i = 0; i < padder; i++) { + if (data[length + i] !== padder) { + throw new Error('PKCS#7 invalid padding byte') + } + } + + var result = createArray(length) + copyArray(data, result, 0, 0, length) + return result + } + + /////////////////////// + // Exporting + + // The block cipher + var aesjs = { + AES: AES, + Counter: Counter, + + ModeOfOperation: { + ecb: ModeOfOperationECB, + cbc: ModeOfOperationCBC, + cfb: ModeOfOperationCFB, + ofb: ModeOfOperationOFB, + ctr: ModeOfOperationCTR + }, + + utils: { + hex: convertHex, + utf8: convertUtf8 + }, + + padding: { + pkcs7: { + pad: pkcs7pad, + strip: pkcs7strip + } + }, + + _arrayTest: { + coerceArray: coerceArray, + createArray: createArray, + copyArray: copyArray + } + } + + // node.js + if (typeof exports !== 'undefined') { + module.exports = aesjs + + // RequireJS/AMD + // http://www.requirejs.org/docs/api.html + // https://github.com/amdjs/amdjs-api/wiki/AMD + } else if (typeof define === 'function' && define.amd) { + define([], function () { + return aesjs + }) + + // Web Browsers + } else { + // If there was an existing library at "aesjs" make sure it's still available + if (root.aesjs) { + aesjs._aesjs = root.aesjs + } + + root.aesjs = aesjs + } +})(this) diff --git a/static/js/crypto/noble-secp256k1.js b/static/js/crypto/noble-secp256k1.js new file mode 100644 index 0000000..01148b0 --- /dev/null +++ b/static/js/crypto/noble-secp256k1.js @@ -0,0 +1,1178 @@ +;(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' + ? factory(exports) + : typeof define === 'function' && define.amd + ? define(['exports'], factory) + : ((global = + typeof globalThis !== 'undefined' ? globalThis : global || self), + factory((global.nobleSecp256k1 = {}))) +})(this, function (exports) { + 'use strict' + + const _nodeResolve_empty = {} + + const nodeCrypto = /*#__PURE__*/ Object.freeze({ + __proto__: null, + default: _nodeResolve_empty + }) + + /*! noble-secp256k1 - MIT License (c) 2019 Paul Miller (paulmillr.com) */ + const _0n = BigInt(0) + const _1n = BigInt(1) + const _2n = BigInt(2) + const _3n = BigInt(3) + const _8n = BigInt(8) + const POW_2_256 = _2n ** BigInt(256) + const CURVE = { + a: _0n, + b: BigInt(7), + P: POW_2_256 - _2n ** BigInt(32) - BigInt(977), + n: POW_2_256 - BigInt('432420386565659656852420866394968145599'), + h: _1n, + Gx: BigInt( + '55066263022277343669578718895168534326250603453777594175500187360389116729240' + ), + Gy: BigInt( + '32670510020758816978083085130507043184471273380659243275938904335757337482424' + ), + beta: BigInt( + '0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee' + ) + } + function weistrass(x) { + const {a, b} = CURVE + const x2 = mod(x * x) + const x3 = mod(x2 * x) + return mod(x3 + a * x + b) + } + const USE_ENDOMORPHISM = CURVE.a === _0n + class JacobianPoint { + constructor(x, y, z) { + this.x = x + this.y = y + this.z = z + } + static fromAffine(p) { + if (!(p instanceof Point)) { + throw new TypeError('JacobianPoint#fromAffine: expected Point') + } + return new JacobianPoint(p.x, p.y, _1n) + } + static toAffineBatch(points) { + const toInv = invertBatch(points.map(p => p.z)) + return points.map((p, i) => p.toAffine(toInv[i])) + } + static normalizeZ(points) { + return JacobianPoint.toAffineBatch(points).map(JacobianPoint.fromAffine) + } + equals(other) { + if (!(other instanceof JacobianPoint)) + throw new TypeError('JacobianPoint expected') + const {x: X1, y: Y1, z: Z1} = this + const {x: X2, y: Y2, z: Z2} = other + const Z1Z1 = mod(Z1 ** _2n) + const Z2Z2 = mod(Z2 ** _2n) + const U1 = mod(X1 * Z2Z2) + const U2 = mod(X2 * Z1Z1) + const S1 = mod(mod(Y1 * Z2) * Z2Z2) + const S2 = mod(mod(Y2 * Z1) * Z1Z1) + return U1 === U2 && S1 === S2 + } + negate() { + return new JacobianPoint(this.x, mod(-this.y), this.z) + } + double() { + const {x: X1, y: Y1, z: Z1} = this + const A = mod(X1 ** _2n) + const B = mod(Y1 ** _2n) + const C = mod(B ** _2n) + const D = mod(_2n * (mod((X1 + B) ** _2n) - A - C)) + const E = mod(_3n * A) + const F = mod(E ** _2n) + const X3 = mod(F - _2n * D) + const Y3 = mod(E * (D - X3) - _8n * C) + const Z3 = mod(_2n * Y1 * Z1) + return new JacobianPoint(X3, Y3, Z3) + } + add(other) { + if (!(other instanceof JacobianPoint)) + throw new TypeError('JacobianPoint expected') + const {x: X1, y: Y1, z: Z1} = this + const {x: X2, y: Y2, z: Z2} = other + if (X2 === _0n || Y2 === _0n) return this + if (X1 === _0n || Y1 === _0n) return other + const Z1Z1 = mod(Z1 ** _2n) + const Z2Z2 = mod(Z2 ** _2n) + const U1 = mod(X1 * Z2Z2) + const U2 = mod(X2 * Z1Z1) + const S1 = mod(mod(Y1 * Z2) * Z2Z2) + const S2 = mod(mod(Y2 * Z1) * Z1Z1) + const H = mod(U2 - U1) + const r = mod(S2 - S1) + if (H === _0n) { + if (r === _0n) { + return this.double() + } else { + return JacobianPoint.ZERO + } + } + const HH = mod(H ** _2n) + const HHH = mod(H * HH) + const V = mod(U1 * HH) + const X3 = mod(r ** _2n - HHH - _2n * V) + const Y3 = mod(r * (V - X3) - S1 * HHH) + const Z3 = mod(Z1 * Z2 * H) + return new JacobianPoint(X3, Y3, Z3) + } + subtract(other) { + return this.add(other.negate()) + } + multiplyUnsafe(scalar) { + const P0 = JacobianPoint.ZERO + if (typeof scalar === 'bigint' && scalar === _0n) return P0 + let n = normalizeScalar(scalar) + if (n === _1n) return this + if (!USE_ENDOMORPHISM) { + let p = P0 + let d = this + while (n > _0n) { + if (n & _1n) p = p.add(d) + d = d.double() + n >>= _1n + } + return p + } + let {k1neg, k1, k2neg, k2} = splitScalarEndo(n) + let k1p = P0 + let k2p = P0 + let d = this + while (k1 > _0n || k2 > _0n) { + if (k1 & _1n) k1p = k1p.add(d) + if (k2 & _1n) k2p = k2p.add(d) + d = d.double() + k1 >>= _1n + k2 >>= _1n + } + if (k1neg) k1p = k1p.negate() + if (k2neg) k2p = k2p.negate() + k2p = new JacobianPoint(mod(k2p.x * CURVE.beta), k2p.y, k2p.z) + return k1p.add(k2p) + } + precomputeWindow(W) { + const windows = USE_ENDOMORPHISM ? 128 / W + 1 : 256 / W + 1 + const points = [] + let p = this + let base = p + for (let window = 0; window < windows; window++) { + base = p + points.push(base) + for (let i = 1; i < 2 ** (W - 1); i++) { + base = base.add(p) + points.push(base) + } + p = base.double() + } + return points + } + wNAF(n, affinePoint) { + if (!affinePoint && this.equals(JacobianPoint.BASE)) + affinePoint = Point.BASE + const W = (affinePoint && affinePoint._WINDOW_SIZE) || 1 + if (256 % W) { + throw new Error( + 'Point#wNAF: Invalid precomputation window, must be power of 2' + ) + } + let precomputes = affinePoint && pointPrecomputes.get(affinePoint) + if (!precomputes) { + precomputes = this.precomputeWindow(W) + if (affinePoint && W !== 1) { + precomputes = JacobianPoint.normalizeZ(precomputes) + pointPrecomputes.set(affinePoint, precomputes) + } + } + let p = JacobianPoint.ZERO + let f = JacobianPoint.ZERO + const windows = 1 + (USE_ENDOMORPHISM ? 128 / W : 256 / W) + const windowSize = 2 ** (W - 1) + const mask = BigInt(2 ** W - 1) + const maxNumber = 2 ** W + const shiftBy = BigInt(W) + for (let window = 0; window < windows; window++) { + const offset = window * windowSize + let wbits = Number(n & mask) + n >>= shiftBy + if (wbits > windowSize) { + wbits -= maxNumber + n += _1n + } + if (wbits === 0) { + let pr = precomputes[offset] + if (window % 2) pr = pr.negate() + f = f.add(pr) + } else { + let cached = precomputes[offset + Math.abs(wbits) - 1] + if (wbits < 0) cached = cached.negate() + p = p.add(cached) + } + } + return {p, f} + } + multiply(scalar, affinePoint) { + let n = normalizeScalar(scalar) + let point + let fake + if (USE_ENDOMORPHISM) { + const {k1neg, k1, k2neg, k2} = splitScalarEndo(n) + let {p: k1p, f: f1p} = this.wNAF(k1, affinePoint) + let {p: k2p, f: f2p} = this.wNAF(k2, affinePoint) + if (k1neg) k1p = k1p.negate() + if (k2neg) k2p = k2p.negate() + k2p = new JacobianPoint(mod(k2p.x * CURVE.beta), k2p.y, k2p.z) + point = k1p.add(k2p) + fake = f1p.add(f2p) + } else { + const {p, f} = this.wNAF(n, affinePoint) + point = p + fake = f + } + return JacobianPoint.normalizeZ([point, fake])[0] + } + toAffine(invZ = invert(this.z)) { + const {x, y, z} = this + const iz1 = invZ + const iz2 = mod(iz1 * iz1) + const iz3 = mod(iz2 * iz1) + const ax = mod(x * iz2) + const ay = mod(y * iz3) + const zz = mod(z * iz1) + if (zz !== _1n) throw new Error('invZ was invalid') + return new Point(ax, ay) + } + } + JacobianPoint.BASE = new JacobianPoint(CURVE.Gx, CURVE.Gy, _1n) + JacobianPoint.ZERO = new JacobianPoint(_0n, _1n, _0n) + const pointPrecomputes = new WeakMap() + class Point { + constructor(x, y) { + this.x = x + this.y = y + } + _setWindowSize(windowSize) { + this._WINDOW_SIZE = windowSize + pointPrecomputes.delete(this) + } + static fromCompressedHex(bytes) { + const isShort = bytes.length === 32 + const x = bytesToNumber(isShort ? bytes : bytes.subarray(1)) + if (!isValidFieldElement(x)) throw new Error('Point is not on curve') + const y2 = weistrass(x) + let y = sqrtMod(y2) + const isYOdd = (y & _1n) === _1n + if (isShort) { + if (isYOdd) y = mod(-y) + } else { + const isFirstByteOdd = (bytes[0] & 1) === 1 + if (isFirstByteOdd !== isYOdd) y = mod(-y) + } + const point = new Point(x, y) + point.assertValidity() + return point + } + static fromUncompressedHex(bytes) { + const x = bytesToNumber(bytes.subarray(1, 33)) + const y = bytesToNumber(bytes.subarray(33, 65)) + const point = new Point(x, y) + point.assertValidity() + return point + } + static fromHex(hex) { + const bytes = ensureBytes(hex) + const len = bytes.length + const header = bytes[0] + if (len === 32 || (len === 33 && (header === 0x02 || header === 0x03))) { + return this.fromCompressedHex(bytes) + } + if (len === 65 && header === 0x04) return this.fromUncompressedHex(bytes) + throw new Error( + `Point.fromHex: received invalid point. Expected 32-33 compressed bytes or 65 uncompressed bytes, not ${len}` + ) + } + static fromPrivateKey(privateKey) { + return Point.BASE.multiply(normalizePrivateKey(privateKey)) + } + static fromSignature(msgHash, signature, recovery) { + msgHash = ensureBytes(msgHash) + const h = truncateHash(msgHash) + const {r, s} = normalizeSignature(signature) + if (recovery !== 0 && recovery !== 1) { + throw new Error('Cannot recover signature: invalid recovery bit') + } + const prefix = recovery & 1 ? '03' : '02' + const R = Point.fromHex(prefix + numTo32bStr(r)) + const {n} = CURVE + const rinv = invert(r, n) + const u1 = mod(-h * rinv, n) + const u2 = mod(s * rinv, n) + const Q = Point.BASE.multiplyAndAddUnsafe(R, u1, u2) + if (!Q) throw new Error('Cannot recover signature: point at infinify') + Q.assertValidity() + return Q + } + toRawBytes(isCompressed = false) { + return hexToBytes(this.toHex(isCompressed)) + } + toHex(isCompressed = false) { + const x = numTo32bStr(this.x) + if (isCompressed) { + const prefix = this.y & _1n ? '03' : '02' + return `${prefix}${x}` + } else { + return `04${x}${numTo32bStr(this.y)}` + } + } + toHexX() { + return this.toHex(true).slice(2) + } + toRawX() { + return this.toRawBytes(true).slice(1) + } + assertValidity() { + const msg = 'Point is not on elliptic curve' + const {x, y} = this + if (!isValidFieldElement(x) || !isValidFieldElement(y)) + throw new Error(msg) + const left = mod(y * y) + const right = weistrass(x) + if (mod(left - right) !== _0n) throw new Error(msg) + } + equals(other) { + return this.x === other.x && this.y === other.y + } + negate() { + return new Point(this.x, mod(-this.y)) + } + double() { + return JacobianPoint.fromAffine(this).double().toAffine() + } + add(other) { + return JacobianPoint.fromAffine(this) + .add(JacobianPoint.fromAffine(other)) + .toAffine() + } + subtract(other) { + return this.add(other.negate()) + } + multiply(scalar) { + return JacobianPoint.fromAffine(this).multiply(scalar, this).toAffine() + } + multiplyAndAddUnsafe(Q, a, b) { + const P = JacobianPoint.fromAffine(this) + const aP = + a === _0n || a === _1n || this !== Point.BASE + ? P.multiplyUnsafe(a) + : P.multiply(a) + const bQ = JacobianPoint.fromAffine(Q).multiplyUnsafe(b) + const sum = aP.add(bQ) + return sum.equals(JacobianPoint.ZERO) ? undefined : sum.toAffine() + } + } + Point.BASE = new Point(CURVE.Gx, CURVE.Gy) + Point.ZERO = new Point(_0n, _0n) + function sliceDER(s) { + return Number.parseInt(s[0], 16) >= 8 ? '00' + s : s + } + function parseDERInt(data) { + if (data.length < 2 || data[0] !== 0x02) { + throw new Error(`Invalid signature integer tag: ${bytesToHex(data)}`) + } + const len = data[1] + const res = data.subarray(2, len + 2) + if (!len || res.length !== len) { + throw new Error(`Invalid signature integer: wrong length`) + } + if (res[0] === 0x00 && res[1] <= 0x7f) { + throw new Error('Invalid signature integer: trailing length') + } + return {data: bytesToNumber(res), left: data.subarray(len + 2)} + } + function parseDERSignature(data) { + if (data.length < 2 || data[0] != 0x30) { + throw new Error(`Invalid signature tag: ${bytesToHex(data)}`) + } + if (data[1] !== data.length - 2) { + throw new Error('Invalid signature: incorrect length') + } + const {data: r, left: sBytes} = parseDERInt(data.subarray(2)) + const {data: s, left: rBytesLeft} = parseDERInt(sBytes) + if (rBytesLeft.length) { + throw new Error( + `Invalid signature: left bytes after parsing: ${bytesToHex(rBytesLeft)}` + ) + } + return {r, s} + } + class Signature { + constructor(r, s) { + this.r = r + this.s = s + this.assertValidity() + } + static fromCompact(hex) { + const arr = isUint8a(hex) + const name = 'Signature.fromCompact' + if (typeof hex !== 'string' && !arr) + throw new TypeError(`${name}: Expected string or Uint8Array`) + const str = arr ? bytesToHex(hex) : hex + if (str.length !== 128) throw new Error(`${name}: Expected 64-byte hex`) + return new Signature( + hexToNumber(str.slice(0, 64)), + hexToNumber(str.slice(64, 128)) + ) + } + static fromDER(hex) { + const arr = isUint8a(hex) + if (typeof hex !== 'string' && !arr) + throw new TypeError(`Signature.fromDER: Expected string or Uint8Array`) + const {r, s} = parseDERSignature(arr ? hex : hexToBytes(hex)) + return new Signature(r, s) + } + static fromHex(hex) { + return this.fromDER(hex) + } + assertValidity() { + const {r, s} = this + if (!isWithinCurveOrder(r)) + throw new Error('Invalid Signature: r must be 0 < r < n') + if (!isWithinCurveOrder(s)) + throw new Error('Invalid Signature: s must be 0 < s < n') + } + hasHighS() { + const HALF = CURVE.n >> _1n + return this.s > HALF + } + normalizeS() { + return this.hasHighS() ? new Signature(this.r, CURVE.n - this.s) : this + } + toDERRawBytes(isCompressed = false) { + return hexToBytes(this.toDERHex(isCompressed)) + } + toDERHex(isCompressed = false) { + const sHex = sliceDER(numberToHexUnpadded(this.s)) + if (isCompressed) return sHex + const rHex = sliceDER(numberToHexUnpadded(this.r)) + const rLen = numberToHexUnpadded(rHex.length / 2) + const sLen = numberToHexUnpadded(sHex.length / 2) + const length = numberToHexUnpadded(rHex.length / 2 + sHex.length / 2 + 4) + return `30${length}02${rLen}${rHex}02${sLen}${sHex}` + } + toRawBytes() { + return this.toDERRawBytes() + } + toHex() { + return this.toDERHex() + } + toCompactRawBytes() { + return hexToBytes(this.toCompactHex()) + } + toCompactHex() { + return numTo32bStr(this.r) + numTo32bStr(this.s) + } + } + function concatBytes(...arrays) { + if (!arrays.every(isUint8a)) throw new Error('Uint8Array list expected') + if (arrays.length === 1) return arrays[0] + const length = arrays.reduce((a, arr) => a + arr.length, 0) + const result = new Uint8Array(length) + for (let i = 0, pad = 0; i < arrays.length; i++) { + const arr = arrays[i] + result.set(arr, pad) + pad += arr.length + } + return result + } + function isUint8a(bytes) { + return bytes instanceof Uint8Array + } + const hexes = Array.from({length: 256}, (v, i) => + i.toString(16).padStart(2, '0') + ) + function bytesToHex(uint8a) { + if (!(uint8a instanceof Uint8Array)) throw new Error('Expected Uint8Array') + let hex = '' + for (let i = 0; i < uint8a.length; i++) { + hex += hexes[uint8a[i]] + } + return hex + } + function numTo32bStr(num) { + if (num > POW_2_256) throw new Error('Expected number < 2^256') + return num.toString(16).padStart(64, '0') + } + function numTo32b(num) { + return hexToBytes(numTo32bStr(num)) + } + function numberToHexUnpadded(num) { + const hex = num.toString(16) + return hex.length & 1 ? `0${hex}` : hex + } + function hexToNumber(hex) { + if (typeof hex !== 'string') { + throw new TypeError('hexToNumber: expected string, got ' + typeof hex) + } + return BigInt(`0x${hex}`) + } + function hexToBytes(hex) { + if (typeof hex !== 'string') { + throw new TypeError('hexToBytes: expected string, got ' + typeof hex) + } + if (hex.length % 2) + throw new Error('hexToBytes: received invalid unpadded hex' + hex.length) + const array = new Uint8Array(hex.length / 2) + for (let i = 0; i < array.length; i++) { + const j = i * 2 + const hexByte = hex.slice(j, j + 2) + const byte = Number.parseInt(hexByte, 16) + if (Number.isNaN(byte) || byte < 0) + throw new Error('Invalid byte sequence') + array[i] = byte + } + return array + } + function bytesToNumber(bytes) { + return hexToNumber(bytesToHex(bytes)) + } + function ensureBytes(hex) { + return hex instanceof Uint8Array ? Uint8Array.from(hex) : hexToBytes(hex) + } + function normalizeScalar(num) { + if (typeof num === 'number' && Number.isSafeInteger(num) && num > 0) + return BigInt(num) + if (typeof num === 'bigint' && isWithinCurveOrder(num)) return num + throw new TypeError('Expected valid private scalar: 0 < scalar < curve.n') + } + function mod(a, b = CURVE.P) { + const result = a % b + return result >= _0n ? result : b + result + } + function pow2(x, power) { + const {P} = CURVE + let res = x + while (power-- > _0n) { + res *= res + res %= P + } + return res + } + function sqrtMod(x) { + const {P} = CURVE + const _6n = BigInt(6) + const _11n = BigInt(11) + const _22n = BigInt(22) + const _23n = BigInt(23) + const _44n = BigInt(44) + const _88n = BigInt(88) + const b2 = (x * x * x) % P + const b3 = (b2 * b2 * x) % P + const b6 = (pow2(b3, _3n) * b3) % P + const b9 = (pow2(b6, _3n) * b3) % P + const b11 = (pow2(b9, _2n) * b2) % P + const b22 = (pow2(b11, _11n) * b11) % P + const b44 = (pow2(b22, _22n) * b22) % P + const b88 = (pow2(b44, _44n) * b44) % P + const b176 = (pow2(b88, _88n) * b88) % P + const b220 = (pow2(b176, _44n) * b44) % P + const b223 = (pow2(b220, _3n) * b3) % P + const t1 = (pow2(b223, _23n) * b22) % P + const t2 = (pow2(t1, _6n) * b2) % P + return pow2(t2, _2n) + } + function invert(number, modulo = CURVE.P) { + if (number === _0n || modulo <= _0n) { + throw new Error( + `invert: expected positive integers, got n=${number} mod=${modulo}` + ) + } + let a = mod(number, modulo) + let b = modulo + let x = _0n, + u = _1n + while (a !== _0n) { + const q = b / a + const r = b % a + const m = x - u * q + ;(b = a), (a = r), (x = u), (u = m) + } + const gcd = b + if (gcd !== _1n) throw new Error('invert: does not exist') + return mod(x, modulo) + } + function invertBatch(nums, p = CURVE.P) { + const scratch = new Array(nums.length) + const lastMultiplied = nums.reduce((acc, num, i) => { + if (num === _0n) return acc + scratch[i] = acc + return mod(acc * num, p) + }, _1n) + const inverted = invert(lastMultiplied, p) + nums.reduceRight((acc, num, i) => { + if (num === _0n) return acc + scratch[i] = mod(acc * scratch[i], p) + return mod(acc * num, p) + }, inverted) + return scratch + } + const divNearest = (a, b) => (a + b / _2n) / b + const POW_2_128 = _2n ** BigInt(128) + function splitScalarEndo(k) { + const {n} = CURVE + const a1 = BigInt('0x3086d221a7d46bcde86c90e49284eb15') + const b1 = -_1n * BigInt('0xe4437ed6010e88286f547fa90abfe4c3') + const a2 = BigInt('0x114ca50f7a8e2f3f657c1108d9d44cfd8') + const b2 = a1 + const c1 = divNearest(b2 * k, n) + const c2 = divNearest(-b1 * k, n) + let k1 = mod(k - c1 * a1 - c2 * a2, n) + let k2 = mod(-c1 * b1 - c2 * b2, n) + const k1neg = k1 > POW_2_128 + const k2neg = k2 > POW_2_128 + if (k1neg) k1 = n - k1 + if (k2neg) k2 = n - k2 + if (k1 > POW_2_128 || k2 > POW_2_128) { + throw new Error('splitScalarEndo: Endomorphism failed, k=' + k) + } + return {k1neg, k1, k2neg, k2} + } + function truncateHash(hash) { + const {n} = CURVE + const byteLength = hash.length + const delta = byteLength * 8 - 256 + let h = bytesToNumber(hash) + if (delta > 0) h = h >> BigInt(delta) + if (h >= n) h -= n + return h + } + class HmacDrbg { + constructor() { + this.v = new Uint8Array(32).fill(1) + this.k = new Uint8Array(32).fill(0) + this.counter = 0 + } + hmac(...values) { + return utils.hmacSha256(this.k, ...values) + } + hmacSync(...values) { + if (typeof utils.hmacSha256Sync !== 'function') + throw new Error('utils.hmacSha256Sync is undefined, you need to set it') + const res = utils.hmacSha256Sync(this.k, ...values) + if (res instanceof Promise) + throw new Error('To use sync sign(), ensure utils.hmacSha256 is sync') + return res + } + incr() { + if (this.counter >= 1000) { + throw new Error('Tried 1,000 k values for sign(), all were invalid') + } + this.counter += 1 + } + async reseed(seed = new Uint8Array()) { + this.k = await this.hmac(this.v, Uint8Array.from([0x00]), seed) + this.v = await this.hmac(this.v) + if (seed.length === 0) return + this.k = await this.hmac(this.v, Uint8Array.from([0x01]), seed) + this.v = await this.hmac(this.v) + } + reseedSync(seed = new Uint8Array()) { + this.k = this.hmacSync(this.v, Uint8Array.from([0x00]), seed) + this.v = this.hmacSync(this.v) + if (seed.length === 0) return + this.k = this.hmacSync(this.v, Uint8Array.from([0x01]), seed) + this.v = this.hmacSync(this.v) + } + async generate() { + this.incr() + this.v = await this.hmac(this.v) + return this.v + } + generateSync() { + this.incr() + this.v = this.hmacSync(this.v) + return this.v + } + } + function isWithinCurveOrder(num) { + return _0n < num && num < CURVE.n + } + function isValidFieldElement(num) { + return _0n < num && num < CURVE.P + } + function kmdToSig(kBytes, m, d) { + const k = bytesToNumber(kBytes) + if (!isWithinCurveOrder(k)) return + const {n} = CURVE + const q = Point.BASE.multiply(k) + const r = mod(q.x, n) + if (r === _0n) return + const s = mod(invert(k, n) * mod(m + d * r, n), n) + if (s === _0n) return + const sig = new Signature(r, s) + const recovery = (q.x === sig.r ? 0 : 2) | Number(q.y & _1n) + return {sig, recovery} + } + function normalizePrivateKey(key) { + let num + if (typeof key === 'bigint') { + num = key + } else if ( + typeof key === 'number' && + Number.isSafeInteger(key) && + key > 0 + ) { + num = BigInt(key) + } else if (typeof key === 'string') { + if (key.length !== 64) throw new Error('Expected 32 bytes of private key') + num = hexToNumber(key) + } else if (isUint8a(key)) { + if (key.length !== 32) throw new Error('Expected 32 bytes of private key') + num = bytesToNumber(key) + } else { + throw new TypeError('Expected valid private key') + } + if (!isWithinCurveOrder(num)) + throw new Error('Expected private key: 0 < key < n') + return num + } + function normalizePublicKey(publicKey) { + if (publicKey instanceof Point) { + publicKey.assertValidity() + return publicKey + } else { + return Point.fromHex(publicKey) + } + } + function normalizeSignature(signature) { + if (signature instanceof Signature) { + signature.assertValidity() + return signature + } + try { + return Signature.fromDER(signature) + } catch (error) { + return Signature.fromCompact(signature) + } + } + function getPublicKey(privateKey, isCompressed = false) { + return Point.fromPrivateKey(privateKey).toRawBytes(isCompressed) + } + function recoverPublicKey( + msgHash, + signature, + recovery, + isCompressed = false + ) { + return Point.fromSignature(msgHash, signature, recovery).toRawBytes( + isCompressed + ) + } + function isPub(item) { + const arr = isUint8a(item) + const str = typeof item === 'string' + const len = (arr || str) && item.length + if (arr) return len === 33 || len === 65 + if (str) return len === 66 || len === 130 + if (item instanceof Point) return true + return false + } + function getSharedSecret(privateA, publicB, isCompressed = false) { + if (isPub(privateA)) + throw new TypeError('getSharedSecret: first arg must be private key') + if (!isPub(publicB)) + throw new TypeError('getSharedSecret: second arg must be public key') + const b = normalizePublicKey(publicB) + b.assertValidity() + return b.multiply(normalizePrivateKey(privateA)).toRawBytes(isCompressed) + } + function bits2int(bytes) { + const slice = bytes.length > 32 ? bytes.slice(0, 32) : bytes + return bytesToNumber(slice) + } + function bits2octets(bytes) { + const z1 = bits2int(bytes) + const z2 = mod(z1, CURVE.n) + return int2octets(z2 < _0n ? z1 : z2) + } + function int2octets(num) { + if (typeof num !== 'bigint') throw new Error('Expected bigint') + const hex = numTo32bStr(num) + return hexToBytes(hex) + } + function initSigArgs(msgHash, privateKey, extraEntropy) { + if (msgHash == null) + throw new Error(`sign: expected valid message hash, not "${msgHash}"`) + const h1 = ensureBytes(msgHash) + const d = normalizePrivateKey(privateKey) + const seedArgs = [int2octets(d), bits2octets(h1)] + if (extraEntropy != null) { + if (extraEntropy === true) extraEntropy = utils.randomBytes(32) + const e = ensureBytes(extraEntropy) + if (e.length !== 32) + throw new Error('sign: Expected 32 bytes of extra data') + seedArgs.push(e) + } + const seed = concatBytes(...seedArgs) + const m = bits2int(h1) + return {seed, m, d} + } + function finalizeSig(recSig, opts) { + let {sig, recovery} = recSig + const {canonical, der, recovered} = Object.assign( + {canonical: true, der: true}, + opts + ) + if (canonical && sig.hasHighS()) { + sig = sig.normalizeS() + recovery ^= 1 + } + const hashed = der ? sig.toDERRawBytes() : sig.toCompactRawBytes() + return recovered ? [hashed, recovery] : hashed + } + async function sign(msgHash, privKey, opts = {}) { + const {seed, m, d} = initSigArgs(msgHash, privKey, opts.extraEntropy) + let sig + const drbg = new HmacDrbg() + await drbg.reseed(seed) + while (!(sig = kmdToSig(await drbg.generate(), m, d))) await drbg.reseed() + return finalizeSig(sig, opts) + } + function signSync(msgHash, privKey, opts = {}) { + const {seed, m, d} = initSigArgs(msgHash, privKey, opts.extraEntropy) + let sig + const drbg = new HmacDrbg() + drbg.reseedSync(seed) + while (!(sig = kmdToSig(drbg.generateSync(), m, d))) drbg.reseedSync() + return finalizeSig(sig, opts) + } + const vopts = {strict: true} + function verify(signature, msgHash, publicKey, opts = vopts) { + let sig + try { + sig = normalizeSignature(signature) + msgHash = ensureBytes(msgHash) + } catch (error) { + return false + } + const {r, s} = sig + if (opts.strict && sig.hasHighS()) return false + const h = truncateHash(msgHash) + let P + try { + P = normalizePublicKey(publicKey) + } catch (error) { + return false + } + const {n} = CURVE + const sinv = invert(s, n) + const u1 = mod(h * sinv, n) + const u2 = mod(r * sinv, n) + const R = Point.BASE.multiplyAndAddUnsafe(P, u1, u2) + if (!R) return false + const v = mod(R.x, n) + return v === r + } + function finalizeSchnorrChallenge(ch) { + return mod(bytesToNumber(ch), CURVE.n) + } + function hasEvenY(point) { + return (point.y & _1n) === _0n + } + class SchnorrSignature { + constructor(r, s) { + this.r = r + this.s = s + this.assertValidity() + } + static fromHex(hex) { + const bytes = ensureBytes(hex) + if (bytes.length !== 64) + throw new TypeError( + `SchnorrSignature.fromHex: expected 64 bytes, not ${bytes.length}` + ) + const r = bytesToNumber(bytes.subarray(0, 32)) + const s = bytesToNumber(bytes.subarray(32, 64)) + return new SchnorrSignature(r, s) + } + assertValidity() { + const {r, s} = this + if (!isValidFieldElement(r) || !isWithinCurveOrder(s)) + throw new Error('Invalid signature') + } + toHex() { + return numTo32bStr(this.r) + numTo32bStr(this.s) + } + toRawBytes() { + return hexToBytes(this.toHex()) + } + } + function schnorrGetPublicKey(privateKey) { + return Point.fromPrivateKey(privateKey).toRawX() + } + function initSchnorrSigArgs(message, privateKey, auxRand) { + if (message == null) + throw new TypeError(`sign: Expected valid message, not "${message}"`) + const m = ensureBytes(message) + const d0 = normalizePrivateKey(privateKey) + const rand = ensureBytes(auxRand) + if (rand.length !== 32) + throw new TypeError('sign: Expected 32 bytes of aux randomness') + const P = Point.fromPrivateKey(d0) + const px = P.toRawX() + const d = hasEvenY(P) ? d0 : CURVE.n - d0 + return {m, P, px, d, rand} + } + function initSchnorrNonce(d, t0h) { + return numTo32b(d ^ bytesToNumber(t0h)) + } + function finalizeSchnorrNonce(k0h) { + const k0 = mod(bytesToNumber(k0h), CURVE.n) + if (k0 === _0n) + throw new Error('sign: Creation of signature failed. k is zero') + const R = Point.fromPrivateKey(k0) + const rx = R.toRawX() + const k = hasEvenY(R) ? k0 : CURVE.n - k0 + return {R, rx, k} + } + function finalizeSchnorrSig(R, k, e, d) { + return new SchnorrSignature(R.x, mod(k + e * d, CURVE.n)).toRawBytes() + } + async function schnorrSign( + message, + privateKey, + auxRand = utils.randomBytes() + ) { + const {m, px, d, rand} = initSchnorrSigArgs(message, privateKey, auxRand) + const t = initSchnorrNonce(d, await utils.taggedHash(TAGS.aux, rand)) + const {R, rx, k} = finalizeSchnorrNonce( + await utils.taggedHash(TAGS.nonce, t, px, m) + ) + const e = finalizeSchnorrChallenge( + await utils.taggedHash(TAGS.challenge, rx, px, m) + ) + const sig = finalizeSchnorrSig(R, k, e, d) + const isValid = await schnorrVerify(sig, m, px) + if (!isValid) throw new Error('sign: Invalid signature produced') + return sig + } + function schnorrSignSync(message, privateKey, auxRand = utils.randomBytes()) { + const {m, px, d, rand} = initSchnorrSigArgs(message, privateKey, auxRand) + const t = initSchnorrNonce(d, utils.taggedHashSync(TAGS.aux, rand)) + const {R, rx, k} = finalizeSchnorrNonce( + utils.taggedHashSync(TAGS.nonce, t, px, m) + ) + const e = finalizeSchnorrChallenge( + utils.taggedHashSync(TAGS.challenge, rx, px, m) + ) + const sig = finalizeSchnorrSig(R, k, e, d) + const isValid = schnorrVerifySync(sig, m, px) + if (!isValid) throw new Error('sign: Invalid signature produced') + return sig + } + function initSchnorrVerify(signature, message, publicKey) { + const raw = signature instanceof SchnorrSignature + const sig = raw ? signature : SchnorrSignature.fromHex(signature) + if (raw) sig.assertValidity() + return { + ...sig, + m: ensureBytes(message), + P: normalizePublicKey(publicKey) + } + } + function finalizeSchnorrVerify(r, P, s, e) { + const R = Point.BASE.multiplyAndAddUnsafe( + P, + normalizePrivateKey(s), + mod(-e, CURVE.n) + ) + if (!R || !hasEvenY(R) || R.x !== r) return false + return true + } + async function schnorrVerify(signature, message, publicKey) { + try { + const {r, s, m, P} = initSchnorrVerify(signature, message, publicKey) + const e = finalizeSchnorrChallenge( + await utils.taggedHash(TAGS.challenge, numTo32b(r), P.toRawX(), m) + ) + return finalizeSchnorrVerify(r, P, s, e) + } catch (error) { + return false + } + } + function schnorrVerifySync(signature, message, publicKey) { + try { + const {r, s, m, P} = initSchnorrVerify(signature, message, publicKey) + const e = finalizeSchnorrChallenge( + utils.taggedHashSync(TAGS.challenge, numTo32b(r), P.toRawX(), m) + ) + return finalizeSchnorrVerify(r, P, s, e) + } catch (error) { + return false + } + } + const schnorr = { + Signature: SchnorrSignature, + getPublicKey: schnorrGetPublicKey, + sign: schnorrSign, + verify: schnorrVerify, + signSync: schnorrSignSync, + verifySync: schnorrVerifySync + } + Point.BASE._setWindowSize(8) + const crypto = { + node: nodeCrypto, + web: typeof self === 'object' && 'crypto' in self ? self.crypto : undefined + } + const TAGS = { + challenge: 'BIP0340/challenge', + aux: 'BIP0340/aux', + nonce: 'BIP0340/nonce' + } + const TAGGED_HASH_PREFIXES = {} + const utils = { + isValidPrivateKey(privateKey) { + try { + normalizePrivateKey(privateKey) + return true + } catch (error) { + return false + } + }, + privateAdd: (privateKey, tweak) => { + const p = normalizePrivateKey(privateKey) + const t = normalizePrivateKey(tweak) + return numTo32b(mod(p + t, CURVE.n)) + }, + privateNegate: privateKey => { + const p = normalizePrivateKey(privateKey) + return numTo32b(CURVE.n - p) + }, + pointAddScalar: (p, tweak, isCompressed) => { + const P = Point.fromHex(p) + const t = normalizePrivateKey(tweak) + const Q = Point.BASE.multiplyAndAddUnsafe(P, t, _1n) + if (!Q) throw new Error('Tweaked point at infinity') + return Q.toRawBytes(isCompressed) + }, + pointMultiply: (p, tweak, isCompressed) => { + const P = Point.fromHex(p) + const t = bytesToNumber(ensureBytes(tweak)) + return P.multiply(t).toRawBytes(isCompressed) + }, + hashToPrivateKey: hash => { + hash = ensureBytes(hash) + if (hash.length < 40 || hash.length > 1024) + throw new Error('Expected 40-1024 bytes of private key as per FIPS 186') + const num = mod(bytesToNumber(hash), CURVE.n - _1n) + _1n + return numTo32b(num) + }, + randomBytes: (bytesLength = 32) => { + if (crypto.web) { + return crypto.web.getRandomValues(new Uint8Array(bytesLength)) + } else if (crypto.node) { + const {randomBytes} = crypto.node + return Uint8Array.from(randomBytes(bytesLength)) + } else { + throw new Error("The environment doesn't have randomBytes function") + } + }, + randomPrivateKey: () => { + return utils.hashToPrivateKey(utils.randomBytes(40)) + }, + bytesToHex, + hexToBytes, + concatBytes, + mod, + invert, + sha256: async (...messages) => { + if (crypto.web) { + const buffer = await crypto.web.subtle.digest( + 'SHA-256', + concatBytes(...messages) + ) + return new Uint8Array(buffer) + } else if (crypto.node) { + const {createHash} = crypto.node + const hash = createHash('sha256') + messages.forEach(m => hash.update(m)) + return Uint8Array.from(hash.digest()) + } else { + throw new Error("The environment doesn't have sha256 function") + } + }, + hmacSha256: async (key, ...messages) => { + if (crypto.web) { + const ckey = await crypto.web.subtle.importKey( + 'raw', + key, + {name: 'HMAC', hash: {name: 'SHA-256'}}, + false, + ['sign'] + ) + const message = concatBytes(...messages) + const buffer = await crypto.web.subtle.sign('HMAC', ckey, message) + return new Uint8Array(buffer) + } else if (crypto.node) { + const {createHmac} = crypto.node + const hash = createHmac('sha256', key) + messages.forEach(m => hash.update(m)) + return Uint8Array.from(hash.digest()) + } else { + throw new Error("The environment doesn't have hmac-sha256 function") + } + }, + sha256Sync: undefined, + hmacSha256Sync: undefined, + taggedHash: async (tag, ...messages) => { + let tagP = TAGGED_HASH_PREFIXES[tag] + if (tagP === undefined) { + const tagH = await utils.sha256( + Uint8Array.from(tag, c => c.charCodeAt(0)) + ) + tagP = concatBytes(tagH, tagH) + TAGGED_HASH_PREFIXES[tag] = tagP + } + return utils.sha256(tagP, ...messages) + }, + taggedHashSync: (tag, ...messages) => { + if (typeof utils.sha256Sync !== 'function') + throw new Error('utils.sha256Sync is undefined, you need to set it') + let tagP = TAGGED_HASH_PREFIXES[tag] + if (tagP === undefined) { + const tagH = utils.sha256Sync( + Uint8Array.from(tag, c => c.charCodeAt(0)) + ) + tagP = concatBytes(tagH, tagH) + TAGGED_HASH_PREFIXES[tag] = tagP + } + return utils.sha256Sync(tagP, ...messages) + }, + precompute(windowSize = 8, point = Point.BASE) { + const cached = point === Point.BASE ? point : new Point(point.x, point.y) + cached._setWindowSize(windowSize) + cached.multiply(_3n) + return cached + } + } + + exports.CURVE = CURVE + exports.Point = Point + exports.Signature = Signature + exports.getPublicKey = getPublicKey + exports.getSharedSecret = getSharedSecret + exports.recoverPublicKey = recoverPublicKey + exports.schnorr = schnorr + exports.sign = sign + exports.signSync = signSync + exports.utils = utils + exports.verify = verify + + Object.defineProperty(exports, '__esModule', {value: true}) +}) diff --git a/static/js/index.js b/static/js/index.js index a6ca6d2..4358670 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,86 +1,466 @@ -const mapObject = obj => { - // obj.date = Quasar.date.formatDate(new Date(obj.time), 'YYYY-MM-DD HH:mm') - // here you can do something with the mapped data - return obj -} - window.app = Vue.createApp({ el: '#vue', - mixins: [windowMixin], - // Declare models/variables + mixins: [window.windowMixin], data() { return { - protocol: window.location.protocol, - location: '//' + window.location.hostname, - thingDialog: { + blindbit: { + url: '' + }, + showScanDialog: false, + scanDialog: { + wallet: null, + lastHeight: 1, + chainTip: null, + oracleTip: null, + loading: false + }, + scanProgress: { + active: false, + current: 0, + total: 0, + found: 0, + walletId: null + }, + config: {sats_denominated: true}, + showBip353Dialog: false, + bip353Address: '', + bip353Result: '', + bip353Loading: false, + qrCodeDialog: { show: false, - data: {} + data: null }, - someBool: true, - splitterModel: 20, - exampleData: [], - tab: 'frameworks', - framworktab: 'fastapi', - usefultab: 'magicalg', - vettedData: '', - baseUrl: window.location.origin + showBroadcastConfirm: false, + sendTxFee: 0, + sendTxAmount: 0, + sendTxVsize: 0, + ...tables, + ...tableData, + walletAccounts: [], + utxosFilter: '', + network: null, + lastScanResult: null, + showSendDialog: false, + sendWallet: null, + sendUtxos: [], + sendForm: { + recipient: '', + amount: 0, + feeRate: 1.0, + memo: '', + useAllUtxos: false + }, + sendLoading: false, + broadcastLoading: false, + sendTxResult: null } }, - // Where functions live + computed: { + sendSelectedTotal: function () { + return this.sendUtxos + .filter(u => u.selected) + .reduce((sum, u) => sum + (u.amount || 0), 0) + }, + canBuildTx: function () { + const hasRecipient = !!this.sendForm.recipient + const hasUtxos = this.sendUtxos.some(u => u.selected) || this.sendForm.useAllUtxos + const hasAmount = this.sendForm.useAllUtxos || this.sendForm.amount > 0 + const hasFee = this.sendForm.feeRate > 0 + return hasRecipient && hasUtxos && hasAmount && hasFee && !this.sendLoading + }, + mempoolBaseUrl: function () { + if (!this.config || !this.config.isLoaded) return 'https://mempool.space' + try { + const endpoint = this.config.mempool_url || 'https://mempool.space' + const withProtocol = endpoint.startsWith('http') + ? endpoint + : `https://${endpoint}` + const url = new URL(withProtocol) + let base = `${url.protocol}//${url.hostname}` + if (url.port) base += `:${url.port}` + return base.replace(/\/$/, '') + } catch (e) { + return 'https://mempool.space' + } + }, + selectedUtxoLabels: function () { + if (!this.sendUtxos || !this.sendUtxos.length) return [] + const labels = [ + ...new Set( + this.sendUtxos + .filter(u => u.selected && u.label) + .map(u => u.label) + ) + ] + return labels +}, + }, + methods: { - exampleFunction(data) { - LNbits.api - .request( - 'GET', // Type of request - '/example/api/v1/test/00000000', // URL of the endpoint - this.g.user.wallets[0].inkey, // Often endpoints require a key - data + fetchUtxos: async function (wallet) { + if (!wallet || !wallet.id) return + try { + const {data} = await LNbits.api.request( + 'GET', + `/siLNt/api/v1/utxos?wallet_id=${wallet.id}`, + this.g.user.wallets[0].inkey ) - .then(response => { - this.exampleData.push(mapObject(response.data)) // Often whats returned is mapped onto some model + + const mappedUtxos = (data.utxos || []).map(u => ({ + txid: u.txid, + amount: u.amount, + vout: u.vout ?? 0, + utxo_state: u.utxo_state, + timestamp: u.timestamp, + label: u.label, + priv_key_tweak: u.priv_key_tweak, + pub_key: u.pub_key, + wallet: wallet.id, + editingLabel: false, // ← UI state + labelDraft: '' // ← UI state + })) + + this.utxos.data = mappedUtxos + this.utxos.total = mappedUtxos + .filter(u => u.utxo_state === 'unspent') + .reduce((total, u) => total + (u.amount || 0), 0) + + this.lastScanResult = { + utxos: mappedUtxos, + timestamp: Date.now() + } + + this.$q.notify({ + type: 'positive', + message: `Loaded ${mappedUtxos.length} UTXO(s) from DB.`, + timeout: 5000 }) - // Error will be passed to the frontend - .catch(LNbits.utils.notifyApiError) + this.tab = 'utxos' + } catch (err) { + LNbits.utils.notifyApiError(err) + } }, - getVettedReadme() { - // This is a function that gets the vetted readme from the LNbits repo and converts it from makrdown to html. - LNbits.api - .request('GET', '/example/api/v1/vetted', this.g.user.wallets[0].inkey) - .then(response => { - this.vettedData = LNbits.utils.convertMarkdown(response.data) + scanWallet: async function (wallet) { + if (!wallet || !wallet.id) return + if (!this.config.blindbit_url) { + this.$q.notify({ + type: 'warning', + message: 'BlindBit Oracle URL not configured. Open Settings to set it.', + timeout: 10000 }) - .catch(LNbits.utils.notifyApiError) + return + } + try { + const {data: freshWallet} = await LNbits.api.request( + 'GET', + `/siLNt/api/v1/wallet/${wallet.id}`, + this.g.user.wallets[0].inkey + ) + wallet = mapWalletAccount(freshWallet) + } catch (err) { + // fall back to cached wallet if fetch fails + logger.warning('Could not refresh wallet from DB, using cached data') + } + // Open dialog immediately with known data + this.scanDialog.wallet = wallet + this.scanDialog.lastHeight = wallet.last_scan_height || wallet.last_height + this.scanDialog.chainTip = null + this.scanDialog.oracleTip = null + this.scanDialog.loading = true + this.showScanDialog = true + + // Fetch chain tip in background + try { + const response = await LNbits.api.request( + 'GET', + '/siLNt/api/v1/oracle/tip', + this.g.user.wallets[0].inkey + ) + const tip = response.data?.height ?? response.data?.block_height + this.scanDialog.chainTip = tip + this.scanDialog.chainTip = tip + } catch (err) { + this.scanDialog.chainTip = null + this.$q.notify({ + type: 'warning', + message: 'Could not fetch chain tip from BlindBit Oracle.', + timeout: 5000 + }) + } finally { + this.scanDialog.loading = false + } + }, + startScan: async function () { + const wallet = this.scanDialog.wallet + this.showScanDialog = false + // Start progress polling + this.scanProgress.active = true + this.scanProgress.current = 0 + this.scanProgress.total = this.scanDialog.chainTip - this.scanDialog.lastHeight + this.scanProgress.found = 0 + this.scanProgress.walletId = wallet.id + const pollInterval = setInterval(async () => { + try { + const {data} = await LNbits.api.request( + 'GET', + `/siLNt/api/v1/wallet/${wallet.id}/scan/progress`, + this.g.user.wallets[0].inkey + ) + this.scanProgress.current = data.current || 0 + this.scanProgress.total = data.total || this.scanProgress.total + this.scanProgress.found = data.found || 0 + if (!data.active) { + clearInterval(pollInterval) + this.scanProgress.active = false + } + } catch (e) { + // ignore poll errors + } + }, 1000) // poll every second + try { + const {data} = await LNbits.api.request( + 'POST', + `/siLNt/api/v1/wallet/${wallet.id}/scan`, + this.g.user.wallets[0].inkey, + { + from_height: this.scanDialog.lastHeight, + to_height: this.scanDialog.chainTip + } + ) + clearInterval(pollInterval) + this.scanProgress.active = false + this.$q.notify({ + type: 'positive', + message: `Scan complete. ${data.utxos_found} Unspent UTXO(s) found across ${data.blocks_scanned} blocks.`, + timeout: 8000 + }) + await this.fetchUtxos(wallet) + } catch (err) { + clearInterval(pollInterval) + this.scanProgress.active = false + LNbits.utils.notifyApiError(err) + } }, - async initWs() { - if (location.protocol !== 'http:') { - localUrl = - 'wss://' + - document.domain + - ':' + - location.port + - '/api/v1/ws/32872r23g29' - } else { - localUrl = - 'ws://' + - document.domain + - ':' + - location.port + - '/api/v1/ws/32872r23g29' + stopScan: async function () { + if (!this.scanProgress.walletId) return + this.scanProgress.stopping = true + try { + await LNbits.api.request( + 'POST', + `/siLNt/api/v1/wallet/${this.scanProgress.walletId}/scan/stop`, + this.g.user.wallets[0].inkey + ) + this.$q.notify({ + type: 'info', + message: 'Stop requested — scan will halt at the next block.', + timeout: 5000 + }) + } catch (err) { + LNbits.utils.notifyApiError(err) + } finally { + this.scanProgress.stopping = false + } + }, + resolveBip353: async function () { + if (!this.bip353Address) return + this.bip353Loading = true + this.bip353Result = '' + try { + const {data} = await LNbits.api.request( + 'GET', + `/siLNt/api/v1/bip353/resolve?address=${encodeURIComponent(this.bip353Address)}`, + this.g.user.wallets[0].inkey + ) + + // Strip bitcoin:?sp= / bitcoin:?lno= / lno= prefixes + let result = data.result || '' + result = result.replace(/^bitcoin:\?sp=/i, '') + result = result.replace(/^bitcoin:\?lno=/i, '') + result = result.replace(/^lno=/i, '') + result = result.replace(/^sp=/i, '') + this.bip353Result = result.trim() + this.$q.notify({ + type: 'positive', + message: `BIP353 resolved for ${this.bip353Address}`, + timeout: 5000 + }) + } catch (err) { + LNbits.utils.notifyApiError(err) + } finally { + this.bip353Loading = false } - this.ws = new WebSocket(localUrl) - this.ws.addEventListener('message', async ({data}) => { - const res = data.toString() - document.getElementById('text-to-change').innerHTML = res + }, + copyText: function (text) { + Quasar.copyToClipboard(text).then(() => { + this.$q.notify({ + message: 'Copied to clipboard!', + position: 'bottom' + }) + }) + }, + clearUtxosForWallet: function (walletId) { + // Remove UTXOs belonging to deleted wallet + const before = this.utxos.data.length + this.utxos.data = this.utxos.data.filter(u => u.wallet !== walletId) + + // Recalculate total from remaining unspent UTXOs + this.utxos.total = this.utxos.data + .filter(u => u.utxo_state === 'unspent') + .reduce((sum, u) => sum + (u.amount || 0), 0) + + // If nothing was filtered (wallet field missing), clear everything + if (this.utxos.data.length === before) { + this.utxos.data = [] + this.utxos.total = 0 + } + + this.lastScanResult = null + + this.$q.notify({ + type: 'info', + message: 'UTXOs cleared for deleted wallet.', + timeout: 5000 }) }, - sendThingDialog() { - console.log(this.thingDialog) - } - }, - // To run on startup - created() { - this.exampleFunction('lorum') - this.initWs() - this.getVettedReadme() + openSendDialog: function (wallet) { + this.sendWallet = wallet + this.sendTxResult = null + this.sendTxAmount = 0 + this.sendTxVsize = 0 + this.sendForm = { + recipient: '', + amount: 0, + feeRate: 1, + memo: '', + useAllUtxos: false + } + // Load unspent UTXOs for this wallet + this.sendUtxos = (this.utxos.data || []) + .filter(u => u.utxo_state === 'unspent' && u.wallet === wallet.id) + .map(u => ({ ...u, selected: false, label: u.label || '' })) + this.showSendDialog = true + }, + + onUseAllUtxos: function (val) { + if (val) { + this.sendUtxos.forEach(u => { u.selected = true }) + this.sendForm.amount = this.sendSelectedTotal + } else { + this.sendUtxos.forEach(u => { u.selected = false }) + this.sendForm.amount = 0 + } + }, + buildTransaction: async function () { + this.sendLoading = true + this.sendTxResult = null + try { + const selectedUtxos = this.sendUtxos.filter(u => u.selected) + const {data} = await LNbits.api.request( + 'POST', + '/siLNt/api/v1/tx/build', + this.g.user.wallets[0].adminkey, + { + wallet_id: this.sendWallet.id, + recipient: this.sendForm.recipient, + amount: this.sendForm.useAllUtxos ? this.sendSelectedTotal : this.sendForm.amount, + fee_rate: Math.max(0.1, parseFloat(this.sendForm.feeRate)) || 0.1, + memo: this.sendForm.memo, + utxos: selectedUtxos.map(u => ({ + txid: u.txid, + amount: u.amount, + label: u.label, + vout: u.vout || 0, + priv_key_tweak: u.priv_key_tweak, + pub_key: u.pub_key + })) + } + ) + this.sendTxResult = data.psbt || data.tx_hex || JSON.stringify(data) + this.sendTxFee = data.fee + this.sendTxAmount = data.amount // ← store actual amount from backend + this.sendTxVsize = data.vsize // ← store vsize for display + this.$q.notify({ + type: 'positive', + message: 'Transaction built successfully.', + timeout: 5000 + }) + } catch (err) { + LNbits.utils.notifyApiError(err) + } finally { + this.sendLoading = false + } + }, + broadcastTransaction: async function () { + this.broadcastLoading = true + try { + const {data} = await LNbits.api.request( + 'POST', + '/siLNt/api/v1/tx/broadcast', + this.g.user.wallets[0].adminkey, + { tx_hex: this.sendTxResult, + wallet_id: this.sendWallet?.id, + spent_txids: this.sendUtxos + .filter(u => u.selected) + .map(u => u.txid) + } + ) + const txid = data.txid + + const mempoolUrl = `${this.mempoolBaseUrl}/tx/${txid}` + // Mark unconfirmed_spent UTXOs in the local list + const selectedTxids = this.sendUtxos + .filter(u => u.selected) + .map(u => u.txid) + + this.utxos.data = this.utxos.data.map(u => + selectedTxids.includes(u.txid) + ? { ...u, utxo_state: 'unconfirmed_spent' } + : u + ) + // Add the broadcasted transaction as a pending entry in the UTXO list + this.utxos.data.push({ + txid: txid, + vout: 0, + amount: 0, + utxo_state: 'broadcasted', + timestamp: Math.floor(Date.now() / 1000), + wallet: this.sendWallet?.id, + priv_key_tweak: '', + pub_key: '', + label: null, + _broadcasted: true + }) + // Recalculate total + this.utxos.total = this.utxos.data + .filter(u => u.utxo_state === 'unspent') + .reduce((sum, u) => sum + (u.amount || 0), 0) + + this.$q.notify({ + type: 'positive', + message: `Transaction broadcast! View on mempool`, + actions: [{ + // label: txid.substring(0, 16) + '...', + lable: 'View on Mempool', + color: 'white', + handler: () => window.open(mempoolUrl, '_blank') + }], + timeout: 15000 + }) + this.showBroadcastConfirm = false + this.showSendDialog = false + this.sendTxResult = null + this.sendTxFee = 0 + this.tab = 'utxos' + } catch (err) { + LNbits.utils.notifyApiError(err) + } finally { + this.broadcastLoading = false + } + }, + confirmBroadcast: function () { + this.showBroadcastConfirm = true + }, + }, + created: async function () { } }) diff --git a/static/js/map.js b/static/js/map.js new file mode 100644 index 0000000..c7d9f2e --- /dev/null +++ b/static/js/map.js @@ -0,0 +1,14 @@ + +const mapWalletAccount = function (o) { + return Object.assign({}, o, { + id: o.id, + title: o.title, + hr_address: o.hr_address, + last_height: o.last_height, + last_scan_height: o.last_scan_height, + balance: o.balance, + sp_address: o.sp_address, + expanded: false + }) +} + diff --git a/static/js/tables.js b/static/js/tables.js new file mode 100644 index 0000000..f437bcd --- /dev/null +++ b/static/js/tables.js @@ -0,0 +1,61 @@ +const tables = { + summaryTable: { + columns: [ + { + name: 'totalInputs', + align: 'center', + label: 'Selected Amount' + }, + { + name: 'totalOutputs', + align: 'center', + label: 'Payed Amount' + }, + { + name: 'fees', + align: 'center', + label: 'Fees' + }, + { + name: 'change', + align: 'center', + label: 'Change' + } + ] + } +} + +const tableData = { + utxos: { + data: [], + total: 0 + }, + payment: { + fee: 0, + txSize: 0, + tx: null, + psbtBase64: '', + psbtBase64Signed: '', + signedTx: null, + signedTxHex: null, + sentTxId: null, + + signModes: [ + { + label: 'Serial Port Device', + value: 'serial-port' + }, + { + label: 'Animated QR', + value: 'animated-qr', + disable: true + } + ], + signMode: '', + show: false, + showAdvanced: false + }, + summary: { + data: [{totalInputs: 0, totalOutputs: 0, fees: 0, change: 0}] + } +} diff --git a/static/js/utils.js b/static/js/utils.js new file mode 100644 index 0000000..946a6dd --- /dev/null +++ b/static/js/utils.js @@ -0,0 +1,210 @@ +const PSBT_BASE64_PREFIX = 'cHNidP8' + +const COMMAND_PING = '/ping' +const COMMAND_PASSWORD = '/password' +const COMMAND_PASSWORD_CLEAR = '/password-clear' +const COMMAND_ADDRESS = '/address' +const COMMAND_SEND_PSBT = '/psbt' +const COMMAND_SIGN_PSBT = '/sign' +const COMMAND_HELP = '/help' +const COMMAND_WIPE = '/wipe' +const COMMAND_SEED = '/seed' +const COMMAND_RESTORE = '/restore' +const COMMAND_CONFIRM_NEXT = '/confirm-next' +const COMMAND_CANCEL = '/cancel' +const COMMAND_XPUB = '/xpub' +const COMMAND_PAIR = '/pair' +const COMMAND_LOG = '/log' +const COMMAND_CHECK_PAIRING = '/check-pairing' + +const DEFAULT_RECEIVE_GAP_LIMIT = 20 +const PAIRING_CONTROL_TEXT = 'lnbits' + +const HWW_DEFAULT_CONFIG = Object.freeze({ + name: '', + buttonOnePin: '', + buttonTwoPin: '', + baudRate: 9600, + bufferSize: 255, + dataBits: 8, + flowControl: 'none', + parity: 'none', + stopBits: 1 +}) + +const blockTimeToDate = blockTime => + blockTime ? moment(blockTime * 1000).format('LLL') : '' + +const currentDateTime = () => moment().format('LLL') + +const sleep = ms => new Promise(r => setTimeout(r, ms)) + +const retryWithDelay = async function (fn, retryCount = 0) { + try { + await sleep(25) + // Do not return the call directly, use result. + // Otherwise the error will not be cought in this try-catch block. + const result = await fn() + return result + } catch (err) { + if (retryCount > 100) throw err + await sleep((retryCount + 1) * 1000) + return retryWithDelay(fn, retryCount + 1) + } +} + +const txSize = tx => { + // https://bitcoinops.org/en/tools/calc-size/ + // overhead size + const nVersion = 4 + const inCount = 1 + const outCount = 1 + const nlockTime = 4 + const hasSegwit = !!tx.inputs.find(inp => + ['p2wsh', 'p2wpkh', 'p2tr'].includes(inp.accountType) + ) + const segwitFlag = hasSegwit ? 0.5 : 0 + const overheadSize = nVersion + inCount + outCount + nlockTime + segwitFlag + + // inputs size + const outpoint = 36 // txId plus vout index number + const scriptSigLength = 1 + const nSequence = 4 + const inputsSize = tx.inputs.reduce((t, inp) => { + const scriptSig = + inp.accountType === 'p2pkh' ? 107 : inp.accountType === 'p2sh' ? 254 : 0 + const witnessItemCount = hasSegwit ? 0.25 : 0 + const witnessItems = + inp.accountType === 'p2wpkh' + ? 27 + : inp.accountType === 'p2wsh' + ? 63.5 + : inp.accountType === 'p2tr' + ? 16.5 + : 0 + t += + outpoint + + scriptSigLength + + nSequence + + scriptSig + + witnessItemCount + + witnessItems + return t + }, 0) + + // outputs size + const nValue = 8 + const scriptPubKeyLength = 1 + + const outputsSize = tx.outputs.reduce((t, out) => { + const type = guessAddressType(out.address) + + const scriptPubKey = + type === 'p2pkh' + ? 25 + : type === 'p2wpkh' + ? 22 + : type === 'p2sh' + ? 23 + : type === 'p2wsh' + ? 34 + : 34 // default to the largest size (p2tr included) + t += nValue + scriptPubKeyLength + scriptPubKey + return t + }, 0) + + return overheadSize + inputsSize + outputsSize +} +const guessAddressType = (a = '') => { + if (a.startsWith('1') || a.startsWith('n')) return 'p2pkh' + if (a.startsWith('3') || a.startsWith('2')) return 'p2sh' + if (a.startsWith('bc1q') || a.startsWith('tb1q')) + return a.length === 42 ? 'p2wpkh' : 'p2wsh' + if (a.startsWith('bc1p') || a.startsWith('tb1p')) return 'p2tr' +} + +const ACCOUNT_TYPES = { + p2tr: 'Taproot, BIP86, P2TR, Bech32m', + p2wpkh: 'SegWit, BIP84, P2WPKH, Bech32', + p2sh: 'BIP49, P2SH-P2WPKH, Base58', + p2pkh: 'Legacy, BIP44, P2PKH, Base58' +} + +const getAccountDescription = type => ACCOUNT_TYPES[type] || 'nonstandard' + +const readFromSerialPort = reader => { + let partialChunk + let fulliness = [] + + const readStringUntil = async (separator = '\n') => { + if (fulliness.length) return fulliness.shift().trim() + const chunks = [] + if (partialChunk) { + // leftovers from previous read + chunks.push(partialChunk) + partialChunk = undefined + } + while (true) { + const {value, done} = await reader.read() + if (value) { + const values = value.split(separator) + // found one or more separators + if (values.length > 1) { + chunks.push(values.shift()) // first element + partialChunk = values.pop() // last element + fulliness = values // full lines + return {value: chunks.join('').trim(), done: false} + } + chunks.push(value) + } + if (done) return {value: chunks.join('').trim(), done: true} + } + } + return readStringUntil +} + +function satOrBtc(val, showUnit = true, showSats = false) { + const value = showSats + ? LNbits.utils.formatSat(val) + : val == 0 + ? 0.0 + : (val / 100000000).toFixed(8) + if (!showUnit) return value + return showSats ? value + ' sat' : value + ' BTC' +} + +function loadTemplateAsync(path) { + const result = new Promise(resolve => { + const xhttp = new XMLHttpRequest() + + xhttp.onreadystatechange = function () { + if (this.readyState == 4) { + if (this.status == 200) resolve(this.responseText) + + if (this.status == 404) resolve(`
Page not found: ${path}
`) + } + } + + xhttp.open('GET', path, true) + xhttp.send() + }) + + return result +} + +function findAccountPathIssues(path = '') { + const p = path.split('/') + if (p[0] !== 'm') return "Path must start with 'm/'" + for (let i = 1; i < p.length; i++) { + if (p[i].endsWith('')) p[i] = p[i].substring(0, p[i].length - 1) + if (isNaN(p[i])) return `${p[i]} is not a valid value` + } +} + +function asciiToUint8Array(str) { + var chars = [] + for (var i = 0; i < str.length; ++i) { + chars.push(str.charCodeAt(i)) + } + return new Uint8Array(chars) +} diff --git a/static/qrcode-example.png b/static/qrcode-example.png deleted file mode 100644 index 29781f5..0000000 Binary files a/static/qrcode-example.png and /dev/null differ diff --git a/static/qrcode-example1.png b/static/qrcode-example1.png deleted file mode 100644 index a6c748f..0000000 Binary files a/static/qrcode-example1.png and /dev/null differ diff --git a/static/quasar-example.png b/static/quasar-example.png deleted file mode 100644 index 64da65a..0000000 Binary files a/static/quasar-example.png and /dev/null differ diff --git a/static/quasar-framework.png b/static/quasar-framework.png deleted file mode 100644 index c06e8cf..0000000 Binary files a/static/quasar-framework.png and /dev/null differ diff --git a/static/quasarlogo.png b/static/quasarlogo.png deleted file mode 100644 index 69228f8..0000000 Binary files a/static/quasarlogo.png and /dev/null differ diff --git a/static/script-example.png b/static/script-example.png deleted file mode 100644 index 813a425..0000000 Binary files a/static/script-example.png and /dev/null differ diff --git a/static/vif-example.png b/static/vif-example.png deleted file mode 100644 index 3e6cfa1..0000000 Binary files a/static/vif-example.png and /dev/null differ diff --git a/static/vuejs-framework.png b/static/vuejs-framework.png deleted file mode 100644 index eb63453..0000000 Binary files a/static/vuejs-framework.png and /dev/null differ diff --git a/static/vuejslogo.png b/static/vuejslogo.png deleted file mode 100644 index 065c4a8..0000000 Binary files a/static/vuejslogo.png and /dev/null differ diff --git a/static/websocket-example.png b/static/websocket-example.png deleted file mode 100644 index 52171d3..0000000 Binary files a/static/websocket-example.png and /dev/null differ diff --git a/swap_crypto.py b/swap_crypto.py new file mode 100644 index 0000000..059c247 --- /dev/null +++ b/swap_crypto.py @@ -0,0 +1,64 @@ +""" +swap_crypto.py — encrypt/decrypt the Boltz refund private key AT REST. + +Reuses the SAME encryption siLNt already uses for email verification tokens +(email_verification.py): LNbits' internal-message helpers, keyed on +settings.auth_secret_key. One encryption path across the extension = one thing +to audit. + +⚠️ Import matches email_verification.py exactly: + from lnbits.helpers import encrypt_internal_message, decrypt_internal_message +The email-verification token base64-wraps the ciphertext for URL-safety; that's +NOT needed here (DB storage, not a URL), so we store the ciphertext directly. + +Threat model: protects against DB-only exposure (dumps, backups, read-only +access). Does NOT protect against full app compromise (attacker with DB + the +server secret can decrypt). Blast radius is one swap's refund, only while that +swap is failed/timed-out with funds still locked. Regtest: legacy plaintext rows +are NOT supported — re-create swaps after deploying. +""" + +# Match email_verification.py exactly: these come from lnbits.helpers. +from lnbits.helpers import encrypt_internal_message, decrypt_internal_message +from loguru import logger + +_PREFIX = "enc:" # marks an encrypted value + + +def _strip_pkcs7(s: str) -> str: + """Remove PKCS#7 block padding that decrypt_internal_message may leave on the + plaintext (observed: a trailing run of 0x10 bytes). Only strips a valid + padding run (final byte value n, 1..16, repeated n times at the end).""" + if not s: + return s + n = ord(s[-1]) + if 1 <= n <= 16 and len(s) >= n and all(ord(c) == n for c in s[-n:]): + return s[:-n] + return s + +def encrypt_refund_key(privkey_hex: str) -> str: + """Encrypt a refund private key for storage. Returns 'enc:'.""" + if not privkey_hex: + return privkey_hex + if privkey_hex.startswith(_PREFIX): + return privkey_hex # idempotent + enc = encrypt_internal_message(privkey_hex) + if not enc: + # Matches email_verification.py, which treats a falsy result as fatal. + raise RuntimeError("Could not encrypt refund key.") + return _PREFIX + enc + + +def decrypt_refund_key(stored: str) -> str: + """Decrypt a stored refund key. Expects an 'enc:'-prefixed value.""" + if not stored: + return stored + if not stored.startswith(_PREFIX): + # On regtest we don't support legacy plaintext — surface loudly rather + # than silently signing with an unexpected value. + raise ValueError("refund key is not encrypted (expected 'enc:' prefix)") + dec = decrypt_internal_message(stored[len(_PREFIX):]) + if not dec: + raise ValueError("Could not decrypt refund key.") + dec = _strip_pkcs7(dec).strip() + return dec \ No newline at end of file diff --git a/tasks.py b/tasks.py deleted file mode 100644 index 8440644..0000000 --- a/tasks.py +++ /dev/null @@ -1,25 +0,0 @@ -# tasks.py is for asynchronous when invoices get paid - -# add your dependencies here - -import asyncio - -from lnbits.core.models import Payment -from lnbits.tasks import register_invoice_listener -from loguru import logger - - -async def wait_for_paid_invoices(): - invoice_queue = asyncio.Queue() - register_invoice_listener(invoice_queue, "ext_example") - - while True: - payment = await invoice_queue.get() - await on_invoice_paid(payment) - - -async def on_invoice_paid(payment: Payment) -> None: - # Will grab any payment with the tag "example" - if payment.extra.get("tag") == "example": - logger.info("example extension received payment") - logger.debug(payment) diff --git a/templates/example/index.html b/templates/example/index.html deleted file mode 100644 index 6c6d094..0000000 --- a/templates/example/index.html +++ /dev/null @@ -1,442 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block scripts %} {{ window_vars(user) }} - - -{% endblock %} {% block page %} - - - - - - -
- Update thing - Create thing - Cancel -
-
-
-
- - - -
- Extension Development Guide - (also check the - docs) -
- - - - - - - - - - - - - - - -
Frameworks
- - - - FASTAPI - - QUASAR - - VUE-JS - - - - - - -

- LNbits API is built using - FastAPI, a high-performance, easy to code API framework.

- FastAPI auto-generates swagger UI docs for testing endpoints - /docs -

- - - TIP: Although it is possible for extensions to - use other extensions API endpoints (such as with the Satspay and - Onchain extension), ideally an extension should only use LNbits - core - endpoints.

- - views.py is used for - setting application routes: -

- views_api.py is used for - setting application API endpoints:
- -
- - -

- LNbits uses - Quasar Framework - for frontend deisgn elements. Quasar Framework is an open-source - Vue.js based framework for building apps. -

- - - TIP: Look through - /template files in - other extensions for examples of Quasar elements being used.

- -

- In the below example we make a dialogue popup box (box can be - triggered - here): Exmple of a tooltip! -

-

-
Useful links:
- Style (typography, spacing, etc) - Genral components (cards, buttons, popup dialogs, etc) - Layouts (rows/columns, flexbox) -
- - - - -

- LNbits uses - Vue - for best-in-class, responsive and high-performance components. -

- -

Typical example of Vue components in a frontend script:

-

- -

- Content can be conditionally rendered using Vue's - v-if: -

- -
-
-
- - -
Useful Tools
-
- - MAGICAL G - EXCHANGE RATES - QR CODES - WEBSOCKETS - - - -
Magical G
-

- A magical "g" (ie - this.g.user.wallets[0].inkey) is always available, with info about the user, wallets and - extensions: -

- {% raw %}{{ g }}{% endraw %} -
- -
Exchange rates
-

- LNbits includes a handy - exchange rate function, that streams rates from 6 different sources. -

- Exchange rate API:
-

- Exchange rate functions, included using - from lnbits.utils.exchange_rates import - fiat_amount_as_satoshis:
- -
- -
QR Codes
-

For most purposes use Quasar's inbuilt VueQrcode library:

- -

- LNbits does also include a handy - - QR code enpoint -

- {% raw %} You can use via - {{protocol + location}}{% endraw - %}/api/v1/qrcode/some-data-you-want-in-a-qrcode:
-
- -
- - -
-
- - -
Websockets
-

- Fastapi includes a great - websocket tool -

- {% raw %} -

- A few LNbits extensions also make use of a weird and useful - websocket/GET tool built into LNbits, such as extensions - Copilot and LNURLDevices
- You can subscribe to websocket with - wss:{{location}}/api/v1/ws/{SOME-ID}
- You can post to any clients subscribed to the endpoint with - {{protocol + - location}}/api/v1/ws/{SOME-ID}/{THE-DATA-YOU-WANT-TO-POST}
-
-

- DEMO: Hit - {{protocol + - location}}/api/v1/ws/32872r23g29/blah%20blah%20blah - in a different browser window to change this text to `blah - blah blah`. -
-
- Function used in this demo:
-

- - {% endraw %} -
-
-
- -
Useful links
- - - - - - - LNbits GitHub Repository - - Includes the LNbits repo and other useful repositories like - lnbits-extensions for submitting extensions - and many LNbits-related projects. - - - - - - - - - - LNbits YouTube Channel - - Features useful tutorials and updates, including how to build - your own extension. - - - - - - - - - - Makerbits YouTube Channel - - Offers great hardware tutorials for projects that integrate - with LNbits. - - - - -
- - -
Good Practice
- Coming soon... -
- - -
Dev Enviroment
- Coming soon... -
- - - -
-
-
-
-
-
-{% endblock %} diff --git a/templates/silnt/_api_docs.html b/templates/silnt/_api_docs.html new file mode 100644 index 0000000..14a2335 --- /dev/null +++ b/templates/silnt/_api_docs.html @@ -0,0 +1,489 @@ + + + + + + + + + + + GET /siLNt/api/v1/wallet +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Query Parameters
+ network=mainnet | testnet (default: mainnet) +
Returns 200 OK (application/json)
+ [<wallet_account_object>, ...] +
Curl example
+ curl -X GET {{ request.base_url }}siLNt/api/v1/wallet -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + GET /siLNt/api/v1/wallet/{wallet_id} +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Returns 200 OK (application/json)
+ {wallet_account_object} +
Curl example
+ curl -X GET {{ request.base_url }}siLNt/api/v1/wallet/<wallet_id> -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + POST /siLNt/api/v1/wallet +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+ {"mnemonic": <encrypted_string>, "title": <string>, + "hr_address": <string>, "last_height": <string>, + "network": "mainnet" | "testnet"} +
Notes
+ If hr_address is provided, it must resolve via BIP353 DNS to this wallet's SP address. +
Returns 200 OK (application/json)
+ "" +
Curl example
+ curl -X POST {{ request.base_url }}siLNt/api/v1/wallet -H + "X-Api-Key: <invoice_key>" -H "Content-Type: application/json" -d + '{"mnemonic": "...", "last_height": "840000", "network": "mainnet"}' + +
+
+
+ + + + + PUT /siLNt/api/v1/wallet/{wallet_id} +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+ {"hr_address": <string>, "last_height": <string>, + "title": <string>, "balance": <integer>} +
Notes
+ If hr_address is updated, it must resolve via BIP353 DNS to this wallet's SP address. +
Returns 200 OK (application/json)
+ "" +
Curl example
+ curl -X PUT {{ request.base_url }}siLNt/api/v1/wallet/<wallet_id> -H + "X-Api-Key: <invoice_key>" -H "Content-Type: application/json" -d + '{"hr_address": "alice@domain.com"}' + +
+
+
+ + + + + DELETE /siLNt/api/v1/wallet/{wallet_id} +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Notes
+ Also deletes all UTXOs and labeled SP addresses for this wallet. +
Returns 204 No Content
+
Curl example
+ curl -X DELETE {{ request.base_url }}siLNt/api/v1/wallet/<wallet_id> -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + + + GET /siLNt/api/v1/wallet/{wallet_id}/addresses +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Returns 200 OK (application/json)
+ {"addresses": [<address_object>, ...], "max": 10} +
Curl example
+ curl -X GET {{ request.base_url }}siLNt/api/v1/wallet/<wallet_id>/addresses -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + POST /siLNt/api/v1/wallet/{wallet_id}/addresses/preview +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+ {"label_index": <integer>} +
Notes
+ Derives a BIP352 labeled SP address without saving to DB. label_index starts at 1 (0 is reserved for change). +
Returns 200 OK (application/json)
+ {"sp_address": <string>, "label_index": <integer>, "wallet_id": <string>} +
Curl example
+ curl -X POST {{ request.base_url }}siLNt/api/v1/wallet/<wallet_id>/addresses/preview -H + "X-Api-Key: <invoice_key>" -H "Content-Type: application/json" -d + '{"label_index": 1}' + +
+
+
+ + + + + POST /siLNt/api/v1/wallet/{wallet_id}/addresses +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+ {"sp_address": <string>, "label_index": <integer>} +
Notes
+ Persists a previously previewed labeled SP address to the DB. Maximum 10 per wallet. +
Returns 200 OK (application/json)
+ {"sp_address": <string>, "label_index": <integer>, "wallet_id": <string>, "id": <string>} +
Curl example
+ curl -X POST {{ request.base_url }}siLNt/api/v1/wallet/<wallet_id>/addresses -H + "X-Api-Key: <invoice_key>" -H "Content-Type: application/json" -d + '{"sp_address": "sp1qq...", "label_index": 1}' + +
+
+
+ + + + + DELETE /siLNt/api/v1/wallet/{wallet_id}/addresses/{address_id} +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Returns 204 No Content
+
Curl example
+ curl -X DELETE {{ request.base_url }}siLNt/api/v1/wallet/<wallet_id>/addresses/<address_id> -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + + + POST /siLNt/api/v1/wallet/{wallet_id}/scan +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+ {"from_height": <integer>, "to_height": <integer>} +
Returns 200 OK (application/json)
+ {"utxos_found": <integer>, "blocks_scanned": <integer>, + "final_height": <integer>, "balance": <integer>} +
Curl example
+ curl -X POST {{ request.base_url }}siLNt/api/v1/wallet/<wallet_id>/scan -H + "X-Api-Key: <invoice_key>" -H "Content-Type: application/json" -d + '{"from_height": 840000, "to_height": 850000}' + +
+
+
+ + + + + POST /siLNt/api/v1/wallet/{wallet_id}/scan/stop +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Returns 200 OK (application/json)
+ {"status": "stop requested"} +
Curl example
+ curl -X POST {{ request.base_url }}siLNt/api/v1/wallet/<wallet_id>/scan/stop -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + GET /siLNt/api/v1/wallet/{wallet_id}/scan/progress +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Returns 200 OK (application/json)
+ {"active": <boolean>, "current": <integer>, + "total": <integer>, "found": <integer>} +
Curl example
+ curl -X GET {{ request.base_url }}siLNt/api/v1/wallet/<wallet_id>/scan/progress -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + + + GET /siLNt/api/v1/utxos +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Query Parameters
+ wallet_id=<string> (required) +
Returns 200 OK (application/json)
+ {"utxos": [<utxo_object>, ...]} +
Curl example
+ curl -X GET "{{ request.base_url }}siLNt/api/v1/utxos?wallet_id=<wallet_id>" -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + + + GET /siLNt/api/v1/backend/config +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Returns 200 OK (application/json)
+ {"blindbit_url": <string>, "blindbit_user": <string>, + "blindbit_pass": <string>, "mempool_url": <string>} +
Curl example
+ curl -X GET {{ request.base_url }}siLNt/api/v1/backend/config -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + PUT /siLNt/api/v1/backend/config +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"blindbit_url": <string>, "blindbit_user": <string>, + "blindbit_pass": <string>, "mempool_url": <string>} +
Notes
+ mempool_url defaults to https://mempool.space if not provided. Supports both http and https. +
Returns 200 OK (application/json)
+ {"blindbit_url": <string>, "blindbit_user": <string>, + "blindbit_pass": <string>, "mempool_url": <string>} +
Curl example
+ curl -X PUT {{ request.base_url }}siLNt/api/v1/backend/config -H + "X-Api-Key: <admin_key>" -H "Content-Type: application/json" -d + '{"blindbit_url": "http://localhost:8001", "mempool_url": "http://localhost:8080"}' + +
+
+
+ + + + + GET /siLNt/api/v1/oracle/tip +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Returns 200 OK (application/json)
+ {"height": <integer>} +
Curl example
+ curl -X GET {{ request.base_url }}siLNt/api/v1/oracle/tip -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + + + GET /siLNt/api/v1/bip353/resolve +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Query Parameters
+ address=<email_format> e.g. alice@domain.com (required) +
Returns 200 OK (application/json)
+ {"address": <string>, "dns_domain": <string>, + "result": <string>} +
Curl example
+ curl -X GET "{{ request.base_url }}siLNt/api/v1/bip353/resolve?address=alice@domain.com" + -H "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + + + POST /siLNt/api/v1/tx/build +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"wallet_id": <string>, "recipient": <string>, + "amount": <integer>, "fee_rate": <integer>, + "memo": <string>, "utxos": [{"txid": <string>, + "amount": <integer>, "vout": <integer>, + "priv_key_tweak": <string>, "pub_key": <string>}]} +
Notes
+ recipient can be a Silent Payment address (sp1...), on-chain address, or BIP353 email address. BIP353 addresses are auto-resolved server-side. +
Returns 200 OK (application/json)
+ {"tx_hex": <string>, "fee": <integer>, + "amount": <integer>, "change": <integer>, + "total_input": <integer>, "recipient": <string>} +
Curl example
+ curl -X POST {{ request.base_url }}siLNt/api/v1/tx/build -H + "X-Api-Key: <admin_key>" -H "Content-Type: application/json" -d + '{"wallet_id": "...", "recipient": "sp1qq...", "amount": 1000, + "fee_rate": 1, "utxos": [...]}' + +
+
+
+ + + + + POST /siLNt/api/v1/tx/broadcast +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"tx_hex": <string>, "wallet_id": <string>, + "spent_txids": [<string>, ...]} +
Notes
+ Broadcasts to configured Mempool URL. spent_txids are marked as unconfirmed_spent in the DB. +
Returns 200 OK (application/json)
+ {"txid": <string>} +
Curl example
+ curl -X POST {{ request.base_url }}siLNt/api/v1/tx/broadcast -H + "X-Api-Key: <admin_key>" -H "Content-Type: application/json" -d + '{"tx_hex": "0200000001...", "wallet_id": "...", "spent_txids": ["..."]}' + +
+
+
+ + + + + + + GET /siLNt/api/v1/config +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Notes
+ Returns mempool_endpoint from BlindBit config (falls back to https://mempool.space). +
Returns 200 OK (application/json)
+ {"mempool_endpoint": <string>, "sats_denominated": <boolean>, + "network": <string>} +
Curl example
+ curl -X GET {{ request.base_url }}siLNt/api/v1/config -H + "X-Api-Key: <invoice_key>" + +
+
+
+ +
\ No newline at end of file diff --git a/templates/silnt/components/utxo-list.html b/templates/silnt/components/utxo-list.html new file mode 100644 index 0000000..59d99d6 --- /dev/null +++ b/templates/silnt/components/utxo-list.html @@ -0,0 +1,211 @@ + + +
+
+ +
+
+ +
+
+
+
+ + +
+ + Unspent: + + + Spent: + +
+ + + + +
+
\ No newline at end of file diff --git a/templates/silnt/components/wallet-config.html b/templates/silnt/components/wallet-config.html new file mode 100644 index 0000000..3b6b084 --- /dev/null +++ b/templates/silnt/components/wallet-config.html @@ -0,0 +1,49 @@ +
+ +
+
+ + +
+
+
+ + + + + + + + +
+ Update + Cancel +
+
+
+
+
\ No newline at end of file diff --git a/templates/silnt/components/wallet-list.html b/templates/silnt/components/wallet-list.html new file mode 100644 index 0000000..8c550a4 --- /dev/null +++ b/templates/silnt/components/wallet-list.html @@ -0,0 +1,366 @@ + + + + + Wallet operations (scan, send, generate labeled addresses) as well as Admin configurations are available + in the Thrilla mobile/web app, where keys are stored + securely on your device. + + + + + + + \ No newline at end of file diff --git a/templates/silnt/index.html b/templates/silnt/index.html new file mode 100644 index 0000000..5350699 --- /dev/null +++ b/templates/silnt/index.html @@ -0,0 +1,494 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + + + + +
+
+ Scanning blockchain... + + / + + blocks +
+
+ + UTXOs found +
+
+ + Stop Scan + +
+
+ +
+ +
+
+ +
+ +
+ + +
+ Thrilla - Silent Payments Wallet +
+
+ + + {% include "silnt/_api_docs.html" %} + +
+
+ + +
+
Send Payment
+ + +
+
+ + +
+
Wallet
+
+
+ Available Balance: + +
+
+ + + + + + + + + + +
+
Select UTXOs to spend
+
+ No unspent UTXOs available. Please scan the blockchain first or Load from DB. +
+ + + + + + + + + + + + + + + + + +
+ + Warning: selected UTXOs have different labels (). + Mixing labeled UTXOs may reduce privacy. + + +
+ Selected total: + +
+ + + + + + + +
+ + Cancel +
+ + +
+ +
Built Transaction (Hex)
+
+ +
+
+ + +
+
+
+
+ + +
+
Scan Blockchain
+ + +
+ + +
+
Wallet
+
+
+
+
+
Last Scan
+ +
+
+
Chain Tip
+
+ +
+ +
+
+
Blocks to Scan
+
+
+
+
+

+ This will scan every block from your wallet's last scan height to the + current chain tip. Scanning may take several minutes depending on the + range. +

+ +
+ + Cancel +
+
+
+ + +
Resolve BIP353 Address
+
+ + + +
+
Resolved Address
+
+ + +
+
+
+ + Close +
+
+
+ + +
+
Confirm Broadcast
+ + +
+ + +
+
Amount
+
+
+ Adjusted from sats to avoid dust change. +
+
+ +
+
Recipient
+
+
+ +
+
Fee
+
+
+
+ +
+ + Cancel +
+
+
+
+ +{% endblock %} {% block vue_templates %} + + + +{% endblock %} {% block scripts %} {{ window_vars(user) }} + + + + + + + + + + + + + + + +{% endblock %} diff --git a/uv.lock b/uv.lock index 7d4cf50..a293d0f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,108 +1,9 @@ version = 1 revision = 3 requires-python = ">=3.10, <3.13" - -[[package]] -name = "aiohappyeyeballs" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, -] - -[[package]] -name = "aiohttp" -version = "3.12.15" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "async-timeout", marker = "python_full_version < '3.11'" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/dc/ef9394bde9080128ad401ac7ede185267ed637df03b51f05d14d1c99ad67/aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc", size = 703921, upload-time = "2025-07-29T05:49:43.584Z" }, - { url = "https://files.pythonhosted.org/packages/8f/42/63fccfc3a7ed97eb6e1a71722396f409c46b60a0552d8a56d7aad74e0df5/aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af", size = 480288, upload-time = "2025-07-29T05:49:47.851Z" }, - { url = "https://files.pythonhosted.org/packages/9c/a2/7b8a020549f66ea2a68129db6960a762d2393248f1994499f8ba9728bbed/aiohttp-3.12.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40b3fee496a47c3b4a39a731954c06f0bd9bd3e8258c059a4beb76ac23f8e421", size = 468063, upload-time = "2025-07-29T05:49:49.789Z" }, - { url = "https://files.pythonhosted.org/packages/8f/f5/d11e088da9176e2ad8220338ae0000ed5429a15f3c9dfd983f39105399cd/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ce13fcfb0bb2f259fb42106cdc63fa5515fb85b7e87177267d89a771a660b79", size = 1650122, upload-time = "2025-07-29T05:49:51.874Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6b/b60ce2757e2faed3d70ed45dafee48cee7bfb878785a9423f7e883f0639c/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3beb14f053222b391bf9cf92ae82e0171067cc9c8f52453a0f1ec7c37df12a77", size = 1624176, upload-time = "2025-07-29T05:49:53.805Z" }, - { url = "https://files.pythonhosted.org/packages/dd/de/8c9fde2072a1b72c4fadecf4f7d4be7a85b1d9a4ab333d8245694057b4c6/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c39e87afe48aa3e814cac5f535bc6199180a53e38d3f51c5e2530f5aa4ec58c", size = 1696583, upload-time = "2025-07-29T05:49:55.338Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ad/07f863ca3d895a1ad958a54006c6dafb4f9310f8c2fdb5f961b8529029d3/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f1b4ce5bc528a6ee38dbf5f39bbf11dd127048726323b72b8e85769319ffc4", size = 1738896, upload-time = "2025-07-29T05:49:57.045Z" }, - { url = "https://files.pythonhosted.org/packages/20/43/2bd482ebe2b126533e8755a49b128ec4e58f1a3af56879a3abdb7b42c54f/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1004e67962efabbaf3f03b11b4c43b834081c9e3f9b32b16a7d97d4708a9abe6", size = 1643561, upload-time = "2025-07-29T05:49:58.762Z" }, - { url = "https://files.pythonhosted.org/packages/23/40/2fa9f514c4cf4cbae8d7911927f81a1901838baf5e09a8b2c299de1acfe5/aiohttp-3.12.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8faa08fcc2e411f7ab91d1541d9d597d3a90e9004180edb2072238c085eac8c2", size = 1583685, upload-time = "2025-07-29T05:50:00.375Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c3/94dc7357bc421f4fb978ca72a201a6c604ee90148f1181790c129396ceeb/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe086edf38b2222328cdf89af0dde2439ee173b8ad7cb659b4e4c6f385b2be3d", size = 1627533, upload-time = "2025-07-29T05:50:02.306Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3f/1f8911fe1844a07001e26593b5c255a685318943864b27b4e0267e840f95/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:79b26fe467219add81d5e47b4a4ba0f2394e8b7c7c3198ed36609f9ba161aecb", size = 1638319, upload-time = "2025-07-29T05:50:04.282Z" }, - { url = "https://files.pythonhosted.org/packages/4e/46/27bf57a99168c4e145ffee6b63d0458b9c66e58bb70687c23ad3d2f0bd17/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b761bac1192ef24e16706d761aefcb581438b34b13a2f069a6d343ec8fb693a5", size = 1613776, upload-time = "2025-07-29T05:50:05.863Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7e/1d2d9061a574584bb4ad3dbdba0da90a27fdc795bc227def3a46186a8bc1/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e153e8adacfe2af562861b72f8bc47f8a5c08e010ac94eebbe33dc21d677cd5b", size = 1693359, upload-time = "2025-07-29T05:50:07.563Z" }, - { url = "https://files.pythonhosted.org/packages/08/98/bee429b52233c4a391980a5b3b196b060872a13eadd41c3a34be9b1469ed/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc49c4de44977aa8601a00edbf157e9a421f227aa7eb477d9e3df48343311065", size = 1716598, upload-time = "2025-07-29T05:50:09.33Z" }, - { url = "https://files.pythonhosted.org/packages/57/39/b0314c1ea774df3392751b686104a3938c63ece2b7ce0ba1ed7c0b4a934f/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2776c7ec89c54a47029940177e75c8c07c29c66f73464784971d6a81904ce9d1", size = 1644940, upload-time = "2025-07-29T05:50:11.334Z" }, - { url = "https://files.pythonhosted.org/packages/1b/83/3dacb8d3f8f512c8ca43e3fa8a68b20583bd25636ffa4e56ee841ffd79ae/aiohttp-3.12.15-cp310-cp310-win32.whl", hash = "sha256:2c7d81a277fa78b2203ab626ced1487420e8c11a8e373707ab72d189fcdad20a", size = 429239, upload-time = "2025-07-29T05:50:12.803Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f9/470b5daba04d558c9673ca2034f28d067f3202a40e17804425f0c331c89f/aiohttp-3.12.15-cp310-cp310-win_amd64.whl", hash = "sha256:83603f881e11f0f710f8e2327817c82e79431ec976448839f3cd05d7afe8f830", size = 452297, upload-time = "2025-07-29T05:50:14.266Z" }, - { url = "https://files.pythonhosted.org/packages/20/19/9e86722ec8e835959bd97ce8c1efa78cf361fa4531fca372551abcc9cdd6/aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117", size = 711246, upload-time = "2025-07-29T05:50:15.937Z" }, - { url = "https://files.pythonhosted.org/packages/71/f9/0a31fcb1a7d4629ac9d8f01f1cb9242e2f9943f47f5d03215af91c3c1a26/aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe", size = 483515, upload-time = "2025-07-29T05:50:17.442Z" }, - { url = "https://files.pythonhosted.org/packages/62/6c/94846f576f1d11df0c2e41d3001000527c0fdf63fce7e69b3927a731325d/aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9", size = 471776, upload-time = "2025-07-29T05:50:19.568Z" }, - { url = "https://files.pythonhosted.org/packages/f8/6c/f766d0aaafcee0447fad0328da780d344489c042e25cd58fde566bf40aed/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5", size = 1741977, upload-time = "2025-07-29T05:50:21.665Z" }, - { url = "https://files.pythonhosted.org/packages/17/e5/fb779a05ba6ff44d7bc1e9d24c644e876bfff5abe5454f7b854cace1b9cc/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728", size = 1690645, upload-time = "2025-07-29T05:50:23.333Z" }, - { url = "https://files.pythonhosted.org/packages/37/4e/a22e799c2035f5d6a4ad2cf8e7c1d1bd0923192871dd6e367dafb158b14c/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16", size = 1789437, upload-time = "2025-07-29T05:50:25.007Z" }, - { url = "https://files.pythonhosted.org/packages/28/e5/55a33b991f6433569babb56018b2fb8fb9146424f8b3a0c8ecca80556762/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0", size = 1828482, upload-time = "2025-07-29T05:50:26.693Z" }, - { url = "https://files.pythonhosted.org/packages/c6/82/1ddf0ea4f2f3afe79dffed5e8a246737cff6cbe781887a6a170299e33204/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b", size = 1730944, upload-time = "2025-07-29T05:50:28.382Z" }, - { url = "https://files.pythonhosted.org/packages/1b/96/784c785674117b4cb3877522a177ba1b5e4db9ce0fd519430b5de76eec90/aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd", size = 1668020, upload-time = "2025-07-29T05:50:30.032Z" }, - { url = "https://files.pythonhosted.org/packages/12/8a/8b75f203ea7e5c21c0920d84dd24a5c0e971fe1e9b9ebbf29ae7e8e39790/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8", size = 1716292, upload-time = "2025-07-29T05:50:31.983Z" }, - { url = "https://files.pythonhosted.org/packages/47/0b/a1451543475bb6b86a5cfc27861e52b14085ae232896a2654ff1231c0992/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50", size = 1711451, upload-time = "2025-07-29T05:50:33.989Z" }, - { url = "https://files.pythonhosted.org/packages/55/fd/793a23a197cc2f0d29188805cfc93aa613407f07e5f9da5cd1366afd9d7c/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676", size = 1691634, upload-time = "2025-07-29T05:50:35.846Z" }, - { url = "https://files.pythonhosted.org/packages/ca/bf/23a335a6670b5f5dfc6d268328e55a22651b440fca341a64fccf1eada0c6/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7", size = 1785238, upload-time = "2025-07-29T05:50:37.597Z" }, - { url = "https://files.pythonhosted.org/packages/57/4f/ed60a591839a9d85d40694aba5cef86dde9ee51ce6cca0bb30d6eb1581e7/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7", size = 1805701, upload-time = "2025-07-29T05:50:39.591Z" }, - { url = "https://files.pythonhosted.org/packages/85/e0/444747a9455c5de188c0f4a0173ee701e2e325d4b2550e9af84abb20cdba/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685", size = 1718758, upload-time = "2025-07-29T05:50:41.292Z" }, - { url = "https://files.pythonhosted.org/packages/36/ab/1006278d1ffd13a698e5dd4bfa01e5878f6bddefc296c8b62649753ff249/aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b", size = 428868, upload-time = "2025-07-29T05:50:43.063Z" }, - { url = "https://files.pythonhosted.org/packages/10/97/ad2b18700708452400278039272032170246a1bf8ec5d832772372c71f1a/aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d", size = 453273, upload-time = "2025-07-29T05:50:44.613Z" }, - { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333, upload-time = "2025-07-29T05:50:46.507Z" }, - { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948, upload-time = "2025-07-29T05:50:48.067Z" }, - { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787, upload-time = "2025-07-29T05:50:49.669Z" }, - { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590, upload-time = "2025-07-29T05:50:51.368Z" }, - { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241, upload-time = "2025-07-29T05:50:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335, upload-time = "2025-07-29T05:50:55.394Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491, upload-time = "2025-07-29T05:50:57.202Z" }, - { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929, upload-time = "2025-07-29T05:50:59.192Z" }, - { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733, upload-time = "2025-07-29T05:51:01.394Z" }, - { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790, upload-time = "2025-07-29T05:51:03.657Z" }, - { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245, upload-time = "2025-07-29T05:51:05.911Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899, upload-time = "2025-07-29T05:51:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459, upload-time = "2025-07-29T05:51:09.56Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434, upload-time = "2025-07-29T05:51:11.423Z" }, - { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045, upload-time = "2025-07-29T05:51:13.689Z" }, - { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591, upload-time = "2025-07-29T05:51:15.452Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, -] - -[[package]] -name = "aiosqlite" -version = "0.21.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" }, +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version < '3.11'", ] [[package]] @@ -129,59 +30,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" }, ] -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, -] - -[[package]] -name = "asyncpg" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "async-timeout", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746, upload-time = "2024-10-20T00:30:41.127Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/07/1650a8c30e3a5c625478fa8aafd89a8dd7d85999bf7169b16f54973ebf2c/asyncpg-0.30.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bfb4dd5ae0699bad2b233672c8fc5ccbd9ad24b89afded02341786887e37927e", size = 673143, upload-time = "2024-10-20T00:29:08.846Z" }, - { url = "https://files.pythonhosted.org/packages/a0/9a/568ff9b590d0954553c56806766914c149609b828c426c5118d4869111d3/asyncpg-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc1f62c792752a49f88b7e6f774c26077091b44caceb1983509edc18a2222ec0", size = 645035, upload-time = "2024-10-20T00:29:12.02Z" }, - { url = "https://files.pythonhosted.org/packages/de/11/6f2fa6c902f341ca10403743701ea952bca896fc5b07cc1f4705d2bb0593/asyncpg-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3152fef2e265c9c24eec4ee3d22b4f4d2703d30614b0b6753e9ed4115c8a146f", size = 2912384, upload-time = "2024-10-20T00:29:13.644Z" }, - { url = "https://files.pythonhosted.org/packages/83/83/44bd393919c504ffe4a82d0aed8ea0e55eb1571a1dea6a4922b723f0a03b/asyncpg-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7255812ac85099a0e1ffb81b10dc477b9973345793776b128a23e60148dd1af", size = 2947526, upload-time = "2024-10-20T00:29:15.871Z" }, - { url = "https://files.pythonhosted.org/packages/08/85/e23dd3a2b55536eb0ded80c457b0693352262dc70426ef4d4a6fc994fa51/asyncpg-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:578445f09f45d1ad7abddbff2a3c7f7c291738fdae0abffbeb737d3fc3ab8b75", size = 2895390, upload-time = "2024-10-20T00:29:19.346Z" }, - { url = "https://files.pythonhosted.org/packages/9b/26/fa96c8f4877d47dc6c1864fef5500b446522365da3d3d0ee89a5cce71a3f/asyncpg-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c42f6bb65a277ce4d93f3fba46b91a265631c8df7250592dd4f11f8b0152150f", size = 3015630, upload-time = "2024-10-20T00:29:21.186Z" }, - { url = "https://files.pythonhosted.org/packages/34/00/814514eb9287614188a5179a8b6e588a3611ca47d41937af0f3a844b1b4b/asyncpg-0.30.0-cp310-cp310-win32.whl", hash = "sha256:aa403147d3e07a267ada2ae34dfc9324e67ccc4cdca35261c8c22792ba2b10cf", size = 568760, upload-time = "2024-10-20T00:29:22.769Z" }, - { url = "https://files.pythonhosted.org/packages/f0/28/869a7a279400f8b06dd237266fdd7220bc5f7c975348fea5d1e6909588e9/asyncpg-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb622c94db4e13137c4c7f98834185049cc50ee01d8f657ef898b6407c7b9c50", size = 625764, upload-time = "2024-10-20T00:29:25.882Z" }, - { url = "https://files.pythonhosted.org/packages/4c/0e/f5d708add0d0b97446c402db7e8dd4c4183c13edaabe8a8500b411e7b495/asyncpg-0.30.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5e0511ad3dec5f6b4f7a9e063591d407eee66b88c14e2ea636f187da1dcfff6a", size = 674506, upload-time = "2024-10-20T00:29:27.988Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a0/67ec9a75cb24a1d99f97b8437c8d56da40e6f6bd23b04e2f4ea5d5ad82ac/asyncpg-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:915aeb9f79316b43c3207363af12d0e6fd10776641a7de8a01212afd95bdf0ed", size = 645922, upload-time = "2024-10-20T00:29:29.391Z" }, - { url = "https://files.pythonhosted.org/packages/5c/d9/a7584f24174bd86ff1053b14bb841f9e714380c672f61c906eb01d8ec433/asyncpg-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c198a00cce9506fcd0bf219a799f38ac7a237745e1d27f0e1f66d3707c84a5a", size = 3079565, upload-time = "2024-10-20T00:29:30.832Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d7/a4c0f9660e333114bdb04d1a9ac70db690dd4ae003f34f691139a5cbdae3/asyncpg-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3326e6d7381799e9735ca2ec9fd7be4d5fef5dcbc3cb555d8a463d8460607956", size = 3109962, upload-time = "2024-10-20T00:29:33.114Z" }, - { url = "https://files.pythonhosted.org/packages/3c/21/199fd16b5a981b1575923cbb5d9cf916fdc936b377e0423099f209e7e73d/asyncpg-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:51da377487e249e35bd0859661f6ee2b81db11ad1f4fc036194bc9cb2ead5056", size = 3064791, upload-time = "2024-10-20T00:29:34.677Z" }, - { url = "https://files.pythonhosted.org/packages/77/52/0004809b3427534a0c9139c08c87b515f1c77a8376a50ae29f001e53962f/asyncpg-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc6d84136f9c4d24d358f3b02be4b6ba358abd09f80737d1ac7c444f36108454", size = 3188696, upload-time = "2024-10-20T00:29:36.389Z" }, - { url = "https://files.pythonhosted.org/packages/52/cb/fbad941cd466117be58b774a3f1cc9ecc659af625f028b163b1e646a55fe/asyncpg-0.30.0-cp311-cp311-win32.whl", hash = "sha256:574156480df14f64c2d76450a3f3aaaf26105869cad3865041156b38459e935d", size = 567358, upload-time = "2024-10-20T00:29:37.915Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0a/0a32307cf166d50e1ad120d9b81a33a948a1a5463ebfa5a96cc5606c0863/asyncpg-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:3356637f0bd830407b5597317b3cb3571387ae52ddc3bca6233682be88bbbc1f", size = 629375, upload-time = "2024-10-20T00:29:39.987Z" }, - { url = "https://files.pythonhosted.org/packages/4b/64/9d3e887bb7b01535fdbc45fbd5f0a8447539833b97ee69ecdbb7a79d0cb4/asyncpg-0.30.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c902a60b52e506d38d7e80e0dd5399f657220f24635fee368117b8b5fce1142e", size = 673162, upload-time = "2024-10-20T00:29:41.88Z" }, - { url = "https://files.pythonhosted.org/packages/6e/eb/8b236663f06984f212a087b3e849731f917ab80f84450e943900e8ca4052/asyncpg-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aca1548e43bbb9f0f627a04666fedaca23db0a31a84136ad1f868cb15deb6e3a", size = 637025, upload-time = "2024-10-20T00:29:43.352Z" }, - { url = "https://files.pythonhosted.org/packages/cc/57/2dc240bb263d58786cfaa60920779af6e8d32da63ab9ffc09f8312bd7a14/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c2a2ef565400234a633da0eafdce27e843836256d40705d83ab7ec42074efb3", size = 3496243, upload-time = "2024-10-20T00:29:44.922Z" }, - { url = "https://files.pythonhosted.org/packages/f4/40/0ae9d061d278b10713ea9021ef6b703ec44698fe32178715a501ac696c6b/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1292b84ee06ac8a2ad8e51c7475aa309245874b61333d97411aab835c4a2f737", size = 3575059, upload-time = "2024-10-20T00:29:46.891Z" }, - { url = "https://files.pythonhosted.org/packages/c3/75/d6b895a35a2c6506952247640178e5f768eeb28b2e20299b6a6f1d743ba0/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5712350388d0cd0615caec629ad53c81e506b1abaaf8d14c93f54b35e3595a", size = 3473596, upload-time = "2024-10-20T00:29:49.201Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e7/3693392d3e168ab0aebb2d361431375bd22ffc7b4a586a0fc060d519fae7/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db9891e2d76e6f425746c5d2da01921e9a16b5a71a1c905b13f30e12a257c4af", size = 3641632, upload-time = "2024-10-20T00:29:50.768Z" }, - { url = "https://files.pythonhosted.org/packages/32/ea/15670cea95745bba3f0352341db55f506a820b21c619ee66b7d12ea7867d/asyncpg-0.30.0-cp312-cp312-win32.whl", hash = "sha256:68d71a1be3d83d0570049cd1654a9bdfe506e794ecc98ad0873304a9f35e411e", size = 560186, upload-time = "2024-10-20T00:29:52.394Z" }, - { url = "https://files.pythonhosted.org/packages/7e/6b/fe1fad5cee79ca5f5c27aed7bd95baee529c1bf8a387435c8ba4fe53d5c1/asyncpg-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:9a0292c6af5c500523949155ec17b7fe01a00ace33b68a476d6b5059f9630305", size = 621064, upload-time = "2024-10-20T00:29:53.757Z" }, -] - -[[package]] -name = "attrs" -version = "25.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, -] - [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -191,126 +39,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] -[[package]] -name = "backports-datetime-fromisoformat" -version = "2.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/71/81/eff3184acb1d9dc3ce95a98b6f3c81a49b4be296e664db8e1c2eeabef3d9/backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d", size = 23588, upload-time = "2024-12-28T20:18:15.017Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/4b/d6b051ca4b3d76f23c2c436a9669f3be616b8cf6461a7e8061c7c4269642/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4", size = 27561, upload-time = "2024-12-28T20:16:47.974Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/e39b0d471e55eb1b5c7c81edab605c02f71c786d59fb875f0a6f23318747/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c", size = 34448, upload-time = "2024-12-28T20:16:50.712Z" }, - { url = "https://files.pythonhosted.org/packages/f2/28/7a5c87c5561d14f1c9af979231fdf85d8f9fad7a95ff94e56d2205e2520a/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b", size = 27093, upload-time = "2024-12-28T20:16:52.994Z" }, - { url = "https://files.pythonhosted.org/packages/80/ba/f00296c5c4536967c7d1136107fdb91c48404fe769a4a6fd5ab045629af8/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4", size = 52836, upload-time = "2024-12-28T20:16:55.283Z" }, - { url = "https://files.pythonhosted.org/packages/e3/92/bb1da57a069ddd601aee352a87262c7ae93467e66721d5762f59df5021a6/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22", size = 52798, upload-time = "2024-12-28T20:16:56.64Z" }, - { url = "https://files.pythonhosted.org/packages/df/ef/b6cfd355982e817ccdb8d8d109f720cab6e06f900784b034b30efa8fa832/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf", size = 52891, upload-time = "2024-12-28T20:16:58.887Z" }, - { url = "https://files.pythonhosted.org/packages/37/39/b13e3ae8a7c5d88b68a6e9248ffe7066534b0cfe504bf521963e61b6282d/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58", size = 52955, upload-time = "2024-12-28T20:17:00.028Z" }, - { url = "https://files.pythonhosted.org/packages/1e/e4/70cffa3ce1eb4f2ff0c0d6f5d56285aacead6bd3879b27a2ba57ab261172/backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc", size = 29323, upload-time = "2024-12-28T20:17:01.125Z" }, - { url = "https://files.pythonhosted.org/packages/62/f5/5bc92030deadf34c365d908d4533709341fb05d0082db318774fdf1b2bcb/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443", size = 27626, upload-time = "2024-12-28T20:17:03.448Z" }, - { url = "https://files.pythonhosted.org/packages/28/45/5885737d51f81dfcd0911dd5c16b510b249d4c4cf6f4a991176e0358a42a/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0", size = 34588, upload-time = "2024-12-28T20:17:04.459Z" }, - { url = "https://files.pythonhosted.org/packages/bc/6d/bd74de70953f5dd3e768c8fc774af942af0ce9f211e7c38dd478fa7ea910/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005", size = 27162, upload-time = "2024-12-28T20:17:06.752Z" }, - { url = "https://files.pythonhosted.org/packages/47/ba/1d14b097f13cce45b2b35db9898957578b7fcc984e79af3b35189e0d332f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75", size = 54482, upload-time = "2024-12-28T20:17:08.15Z" }, - { url = "https://files.pythonhosted.org/packages/25/e9/a2a7927d053b6fa148b64b5e13ca741ca254c13edca99d8251e9a8a09cfe/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45", size = 54362, upload-time = "2024-12-28T20:17:10.605Z" }, - { url = "https://files.pythonhosted.org/packages/c1/99/394fb5e80131a7d58c49b89e78a61733a9994885804a0bb582416dd10c6f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01", size = 54162, upload-time = "2024-12-28T20:17:12.301Z" }, - { url = "https://files.pythonhosted.org/packages/88/25/1940369de573c752889646d70b3fe8645e77b9e17984e72a554b9b51ffc4/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6", size = 54118, upload-time = "2024-12-28T20:17:13.609Z" }, - { url = "https://files.pythonhosted.org/packages/b7/46/f275bf6c61683414acaf42b2df7286d68cfef03e98b45c168323d7707778/backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061", size = 29329, upload-time = "2024-12-28T20:17:16.124Z" }, - { url = "https://files.pythonhosted.org/packages/a2/0f/69bbdde2e1e57c09b5f01788804c50e68b29890aada999f2b1a40519def9/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147", size = 27630, upload-time = "2024-12-28T20:17:19.442Z" }, - { url = "https://files.pythonhosted.org/packages/d5/1d/1c84a50c673c87518b1adfeafcfd149991ed1f7aedc45d6e5eac2f7d19d7/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4", size = 34707, upload-time = "2024-12-28T20:17:21.79Z" }, - { url = "https://files.pythonhosted.org/packages/71/44/27eae384e7e045cda83f70b551d04b4a0b294f9822d32dea1cbf1592de59/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35", size = 27280, upload-time = "2024-12-28T20:17:24.503Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7a/a4075187eb6bbb1ff6beb7229db5f66d1070e6968abeb61e056fa51afa5e/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1", size = 55094, upload-time = "2024-12-28T20:17:25.546Z" }, - { url = "https://files.pythonhosted.org/packages/71/03/3fced4230c10af14aacadc195fe58e2ced91d011217b450c2e16a09a98c8/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32", size = 55605, upload-time = "2024-12-28T20:17:29.208Z" }, - { url = "https://files.pythonhosted.org/packages/f6/0a/4b34a838c57bd16d3e5861ab963845e73a1041034651f7459e9935289cfd/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d", size = 55353, upload-time = "2024-12-28T20:17:32.433Z" }, - { url = "https://files.pythonhosted.org/packages/d9/68/07d13c6e98e1cad85606a876367ede2de46af859833a1da12c413c201d78/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7", size = 55298, upload-time = "2024-12-28T20:17:34.919Z" }, - { url = "https://files.pythonhosted.org/packages/60/33/45b4d5311f42360f9b900dea53ab2bb20a3d61d7f9b7c37ddfcb3962f86f/backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d", size = 29375, upload-time = "2024-12-28T20:17:36.018Z" }, - { url = "https://files.pythonhosted.org/packages/be/03/7eaa9f9bf290395d57fd30d7f1f2f9dff60c06a31c237dc2beb477e8f899/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039", size = 28980, upload-time = "2024-12-28T20:18:06.554Z" }, - { url = "https://files.pythonhosted.org/packages/47/80/a0ecf33446c7349e79f54cc532933780341d20cff0ee12b5bfdcaa47067e/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06", size = 28449, upload-time = "2024-12-28T20:18:07.77Z" }, -] - -[[package]] -name = "base58" -version = "2.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7f/45/8ae61209bb9015f516102fa559a2914178da1d5868428bd86a1b4421141d/base58-2.1.1.tar.gz", hash = "sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c", size = 6528, upload-time = "2021-10-30T22:12:17.858Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/45/ec96b29162a402fc4c1c5512d114d7b3787b9d1c2ec241d9568b4816ee23/base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2", size = 5621, upload-time = "2021-10-30T22:12:16.658Z" }, -] - -[[package]] -name = "bech32" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ab/fe/b67ac9b123e25a3c1b8fc3f3c92648804516ab44215adb165284e024c43f/bech32-1.2.0.tar.gz", hash = "sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899", size = 3695, upload-time = "2020-02-17T15:31:09.763Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/41/7022a226e5a6ac7091a95ba36bad057012ab7330b9894ad4e14e31d0b858/bech32-1.2.0-py3-none-any.whl", hash = "sha256:990dc8e5a5e4feabbdf55207b5315fdd9b73db40be294a19b3752cde9e79d981", size = 4587, upload-time = "2020-02-17T15:31:08.299Z" }, -] - -[[package]] -name = "bitarray" -version = "3.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/56/844ba2a8e272146ece382eb529893858cee82b8c4048be69eef8b488dabd/bitarray-3.7.0.tar.gz", hash = "sha256:ae8fb4107c7a43f8979875dc465cab605b75b108e0db7a92da1a727128d0c865", size = 151501, upload-time = "2025-08-24T18:55:21.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/c1/4527788f0ae9e0cd548f8c8fb69d46bba2d96238cbc2cd42280fed99d922/bitarray-3.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:867cf634303387cced450b3901d89743469cd11ae9253840a294c3b6d1c1ec0e", size = 147706, upload-time = "2025-08-24T18:51:53.845Z" }, - { url = "https://files.pythonhosted.org/packages/ef/83/20c896782e22ef173104050b9002e0b497a8d76bfac28fb059f7e92f336b/bitarray-3.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41a2123ef755bbbfed688d9a95accf64031ccf840429f8eb36c7c04a784a22e", size = 144027, upload-time = "2025-08-24T18:51:56.069Z" }, - { url = "https://files.pythonhosted.org/packages/cf/9c/150f782406b512e024441588595c1c550b57125ced36ce0cd1307c36d709/bitarray-3.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c35ece0ba07e177c944e4f26cd91158bcecdce8745902d891ed07ddf58b018", size = 319953, upload-time = "2025-08-24T18:51:57.582Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/b479deee3844e59c689e872d9ca7b2e4942c5059c9bb586941b954c4b755/bitarray-3.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eff0a7f5ce1c0c7845e9a2e9150e6d747e54166b8bf26b81e5d1f50e42efb1a8", size = 339058, upload-time = "2025-08-24T18:51:58.717Z" }, - { url = "https://files.pythonhosted.org/packages/0e/17/a9bc75c33115d598e816107c56a3bf6338c59ba5bda7f165b9db98e53e25/bitarray-3.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9f3b79eeee60d26e5e18fab1523a42551065e8ea31ae0bce6705a4a91dc136b", size = 329552, upload-time = "2025-08-24T18:52:00.372Z" }, - { url = "https://files.pythonhosted.org/packages/76/d4/60ab745c6c7c3f8b5d7f968597a5df4232fb90224c0bc92ba0aa039ecf8c/bitarray-3.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ec2fb71937428e095100105244c42591b83d6cc3f5047024ac25d2fa1be7374", size = 322192, upload-time = "2025-08-24T18:52:01.774Z" }, - { url = "https://files.pythonhosted.org/packages/0d/3e/5b5fed780a6e601cfa500b76c5271e46b68019217bb1cc11b6c8733bcd2e/bitarray-3.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e8dcbfe78fec5040a1e5921ba0d9b8589430a6684be494eeef61592504e2697", size = 310486, upload-time = "2025-08-24T18:52:02.946Z" }, - { url = "https://files.pythonhosted.org/packages/ea/0d/7c16628d7cc44781e5d1c768b12dcacb94f8f61afd2e7895b5f4f7502e54/bitarray-3.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0393c51eb1b19a442c5a97d48c9f1d56e2ee4893fb98801c22860d71560ce716", size = 314849, upload-time = "2025-08-24T18:52:04.193Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ba/d13a7e838c382d022036a4a844e7723042167b7d7ae5d73d573c57e91b79/bitarray-3.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:900b201fa8bd0d9761be74a5954eb7986dc486064120edce6007186ce793c116", size = 311277, upload-time = "2025-08-24T18:52:05.557Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ac/4232f9f0215d669f6d2f4042a743ef21f0aa7c59861f97be0e450ff37190/bitarray-3.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42e8f4c9b1e04134d1241b70aa7746633d97b4789036f3437217e730470aefb8", size = 339737, upload-time = "2025-08-24T18:52:07.007Z" }, - { url = "https://files.pythonhosted.org/packages/11/26/8e59621bbffb503a728e3329c12549e3348b8ff0c11b44204557684d7d1d/bitarray-3.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1081e4f94ce12165e5780df31a5aa16ff454cab241276b464a908a93b83f0840", size = 339715, upload-time = "2025-08-24T18:52:08.441Z" }, - { url = "https://files.pythonhosted.org/packages/bd/76/bf921d8bbb77004379e2cf424955a8472fa85f7c27a989b7b459432252d4/bitarray-3.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8029bbe15e832c8d38bcac9b08e3c34659f63ba86003be3940c24619d7549ce2", size = 320138, upload-time = "2025-08-24T18:52:09.476Z" }, - { url = "https://files.pythonhosted.org/packages/1e/86/dab491dcc8fdf0af56bef28018545bc7eb73175032b456d625cb365bf4c3/bitarray-3.7.0-cp310-cp310-win32.whl", hash = "sha256:530398cc5b2bf0ee7df25364fab6d6a831aeb7f4af66743f8fdd7c03fc8cbc46", size = 141258, upload-time = "2025-08-24T18:52:10.526Z" }, - { url = "https://files.pythonhosted.org/packages/99/3a/61dff96f49cc03460403141b053dfbc89fe6dde5aecbb2e049ebac0f0469/bitarray-3.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:0f9fdd7d3e52be4eb8d5459257de7ad80b4607bf8d6e6ce62e7b68815213c081", size = 148018, upload-time = "2025-08-24T18:52:11.811Z" }, - { url = "https://files.pythonhosted.org/packages/0a/22/525b1df71dd3b1acaa284301121fd3a38c5625bd497e9c07a9b1ca077c84/bitarray-3.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:416f7d6643ea25ee0e6ebac00408fc56a821cf69f2b5fd9e21ff63be519c75b9", size = 147704, upload-time = "2025-08-24T18:52:12.871Z" }, - { url = "https://files.pythonhosted.org/packages/3c/78/3ef00b7c265110e923d39e16b6efc0271d88c9bb6b728d48b299bba26c27/bitarray-3.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fc11c850c6d1b3c6afa77c66d013c133b90f122202cf1041ee4b74fde4063ed", size = 144024, upload-time = "2025-08-24T18:52:13.937Z" }, - { url = "https://files.pythonhosted.org/packages/0e/7f/4a88ffac4e42d4fc44a0a76ab39dc766f7dabd5f8eed9b2feb9a83ded520/bitarray-3.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea1f9b66ba0654e6df9737a0328ce299f9f7224c2cd618ead3b01991ec76b13e", size = 327580, upload-time = "2025-08-24T18:52:15.053Z" }, - { url = "https://files.pythonhosted.org/packages/10/9d/b8d927ba8ded5128a8d33f1a08757ec1bedd011d80bb168fe877f2272181/bitarray-3.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c0da2fd1a6f7bbb2a814b998e205322ac65e910518cd4c6198f5671313a30b45", size = 347640, upload-time = "2025-08-24T18:52:16.556Z" }, - { url = "https://files.pythonhosted.org/packages/26/eb/59dad077946d57c017591c03465c9aee95c21049625e5a240032f7c6f07c/bitarray-3.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f0e5f825afb8f3fc8394b9e0eff72caf0cca96bb99e46d19712cc6206f03565", size = 338634, upload-time = "2025-08-24T18:52:18.687Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6c/4ac3288fdb789ea8ce3fa26b03342208234bfd8e3a71c2142250ecbda4b5/bitarray-3.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3c75a39e5483c47d3b0b5977667972784ec5bbbe1d99b83bf7b1c8cd5144fe1", size = 330042, upload-time = "2025-08-24T18:52:19.87Z" }, - { url = "https://files.pythonhosted.org/packages/e6/53/0a8f582dd4743a86c4bbcc2df6220f3a7b5d87743101b06ad6ce2ff26d3b/bitarray-3.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:98578730cf1550f7f1668dc3bf255f8377a98c85247cc5c5a8de33d4f94863d8", size = 318838, upload-time = "2025-08-24T18:52:21.434Z" }, - { url = "https://files.pythonhosted.org/packages/98/ea/29e5b9e167e960982a244f7f898187fdb303ed01fbe11aea5488da273ea4/bitarray-3.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f44d6c93fdd2963c57ccc4c097886c39eef147834739a3b313f948456c125d67", size = 322826, upload-time = "2025-08-24T18:52:22.912Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c0/044f819f680da8d551ed8817e862e36d8c7aa7303d823b9956c860f821d6/bitarray-3.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:49cace19eef85897407adba86b5adee5b4dfdf22ce3ee6cee75e7a29c30cf15f", size = 318831, upload-time = "2025-08-24T18:52:24.029Z" }, - { url = "https://files.pythonhosted.org/packages/ff/12/1d23f9ff8fb7108c84533481fa639744bb33ca669cb9837361177b617377/bitarray-3.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eb4e79abbc15bda5e3270a73b1082515f28df876c5d4ce3f7c4d92f908f88047", size = 347446, upload-time = "2025-08-24T18:52:25.216Z" }, - { url = "https://files.pythonhosted.org/packages/3a/0c/8faf9a0a16e85f61e3b445cf8a6fa4304bc46f579559fdfc8505558cba2a/bitarray-3.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1c40e33b4cf24b3cb5a528ec938f40d5fc67dc94d9cf5a68b6d7a9b376af02ed", size = 347778, upload-time = "2025-08-24T18:52:27.153Z" }, - { url = "https://files.pythonhosted.org/packages/e8/4b/818b1f8eb06804bd5769db014ba6b27b873b6c2ca3698c3a012b34ab8171/bitarray-3.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f074a890ed64527644449777f7e0cb61428bc35544e748d920a82b0fffaaab94", size = 328254, upload-time = "2025-08-24T18:52:28.313Z" }, - { url = "https://files.pythonhosted.org/packages/ff/d5/a67d239de4abaa275a1275ec54f433e7d9101a5b5fa195aae4d262168f91/bitarray-3.7.0-cp311-cp311-win32.whl", hash = "sha256:328bf5aa8c810dc79394c6f6bc690914cb292288fffb84cca8d28dd9cd407261", size = 141412, upload-time = "2025-08-24T18:52:30.225Z" }, - { url = "https://files.pythonhosted.org/packages/f3/4c/fcc8ff363e8a41339708b5446b9f2553fad019a5db915c646587f41d460d/bitarray-3.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:f6a9ffe3630984066a80c5878ec3f3993e04633a7d0cafcf34a9e5e8f8c7636c", size = 148232, upload-time = "2025-08-24T18:52:31.304Z" }, - { url = "https://files.pythonhosted.org/packages/be/db/c6f1dd4b35ce2da758d7c4550becc70e856775f3a734e2b548c0ad943805/bitarray-3.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f721e616338f41d064ce52fb91d195e8a022f5881dd3773fa8cb1275fbd343b2", size = 147404, upload-time = "2025-08-24T18:52:32.654Z" }, - { url = "https://files.pythonhosted.org/packages/01/42/3f63f936c54ca519a9f2769a7d81a34aad31c31a7c6f8cd96dd9a903443a/bitarray-3.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aa951121660396c284e12522f7dc66ac2229874513c33486aceae2819fd53eba", size = 143978, upload-time = "2025-08-24T18:52:34.047Z" }, - { url = "https://files.pythonhosted.org/packages/1f/74/d4d28a0385f52913c020600b6051bdfefa205971d95e58451b1ab93f2ff0/bitarray-3.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76c3770dcfea68f50f65a38ead040a8faea806c507ab565d9b6decd5b1d33629", size = 330333, upload-time = "2025-08-24T18:52:35.371Z" }, - { url = "https://files.pythonhosted.org/packages/4c/9a/ed56824f6903af41a69f22e95fec8e5d894b4fe7c6c625b9d2339695366f/bitarray-3.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a35d2d26913c08ed1524ac170cacfb94f792bd92dd0f18216a1f02216550faee", size = 349618, upload-time = "2025-08-24T18:52:36.963Z" }, - { url = "https://files.pythonhosted.org/packages/35/20/2b2e1d9b6f5384041215253e9947a5078d97e46fdb4e1d64d119f4dec125/bitarray-3.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86372794fa347b711bc07c32b37b3dac1f73c1113356e6525db1b51f7d2c37d8", size = 341305, upload-time = "2025-08-24T18:52:38.272Z" }, - { url = "https://files.pythonhosted.org/packages/17/a7/aa390fb95e5e79428ee1423323e445de634defae330b3bb888649b4dac05/bitarray-3.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2826e7e1857f6dbe27fcc40830ddce8e40bc83cd1f6d53647145ca17eba55ec3", size = 332930, upload-time = "2025-08-24T18:52:39.482Z" }, - { url = "https://files.pythonhosted.org/packages/25/3a/9e0ebd9f639948f1789b27f7a1738ea8a91a0861fe3b44ffd2799164e566/bitarray-3.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3e21a15b451d0aa1fdb40d6b1b0a0f9f8b490a1f9603681e0e95cfdbd9001b83", size = 321167, upload-time = "2025-08-24T18:52:41.122Z" }, - { url = "https://files.pythonhosted.org/packages/99/c1/16b63b4b12140ab2e8d48fc0cd6cd6ed20fcb1b23a89ba0b9071dff98195/bitarray-3.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0420f1af0f1e33b179f6250dfa121fbe53309920a62e5caccc5d6d28511587bf", size = 325053, upload-time = "2025-08-24T18:52:42.755Z" }, - { url = "https://files.pythonhosted.org/packages/59/02/988957c60fcb6bcd8350e5537ec6a42921a3d69badd129b1aad794130cc5/bitarray-3.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:aa8933d21af63c8e2bb01a93502105e748401038300bd592eb302f3393941217", size = 321871, upload-time = "2025-08-24T18:52:45.016Z" }, - { url = "https://files.pythonhosted.org/packages/55/71/782065704445c594866bf944d2394c3ca977f77ed681afa293f479e2a613/bitarray-3.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7e6bf5dcb390b0cab2f687cf124de24ca5a844ef53478cb879c88390770dc5fb", size = 349406, upload-time = "2025-08-24T18:52:46.305Z" }, - { url = "https://files.pythonhosted.org/packages/9c/04/17b6b97826c44e9acd5506e41c39b4ac9d02cacdb48d0af4c497dbc50e67/bitarray-3.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e1dc968c6cc79d255b54892c72958da4f9468ef780ca412b9343844421617f8", size = 350564, upload-time = "2025-08-24T18:52:47.647Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c0/f38222eb223fce62b407ee067a6338dd23a08e79518fa23127e6ab39822b/bitarray-3.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdba3ea398176322b56ce29b811415b44d2b516afb522c7e74f1f6d52115718f", size = 331420, upload-time = "2025-08-24T18:52:48.945Z" }, - { url = "https://files.pythonhosted.org/packages/cd/22/73cb7b49407764648ab5778f95cf1228922d6ece3614df392ef1ff174d1d/bitarray-3.7.0-cp312-cp312-win32.whl", hash = "sha256:b1745ce9e0f957b543ef5cb79ade885d4c29b03dd175663aba1a95053307e737", size = 141505, upload-time = "2025-08-24T18:52:50.121Z" }, - { url = "https://files.pythonhosted.org/packages/8e/83/81b203dcd5986e9dba866a5e75a8f94e0d3d8b59c6dc76df211ff8d33740/bitarray-3.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:3c2a5c72e20368195b967957b47a05f56eb7ca6f81bd41e2720924e7a76885e8", size = 148496, upload-time = "2025-08-24T18:52:51.6Z" }, - { url = "https://files.pythonhosted.org/packages/d2/32/a529ba3a9db1374b9745b7e53ef984af472893b2fd3dcbcff1b56f71747b/bitarray-3.7.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:35833dc4f6418961fb1ded5001979f5130190d0a1cf293f6dde4557046d0fac4", size = 143479, upload-time = "2025-08-24T18:54:39.028Z" }, - { url = "https://files.pythonhosted.org/packages/9b/1c/021d6722b494fcc454e7ee68966663857e6c66355165fd0da0cfddf5c418/bitarray-3.7.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:00f7278844a9b0d4e38f6397de490680d84d7baaf68249d778e38339e36b3359", size = 139925, upload-time = "2025-08-24T18:54:40.859Z" }, - { url = "https://files.pythonhosted.org/packages/19/db/5b5e4b969ebe97be4bce78fbf0ae0f985bf7e35ffafbdb1638a176bfaaaa/bitarray-3.7.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96909e8049c78e593269b186fab2a270d803641b595074b6c0352d10a1b4a297", size = 148826, upload-time = "2025-08-24T18:54:42.713Z" }, - { url = "https://files.pythonhosted.org/packages/93/4a/8260925b0c8f2b731de5ae094a274fe56b74edb055a6bbce07cdd26dcab7/bitarray-3.7.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8d7efe74c83b6ea1845e054a70001343b020ef930894869916069989f6b4293", size = 149665, upload-time = "2025-08-24T18:54:44.589Z" }, - { url = "https://files.pythonhosted.org/packages/48/6f/f4fdc007a73e28ae39effef9cb50f8fbd0ca8e3532d458fd96a903a3ba14/bitarray-3.7.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2cf889b98c82fe1fc8c507e6ace4fc986a89203e2079f073d05af12414a97617", size = 151398, upload-time = "2025-08-24T18:54:46.411Z" }, - { url = "https://files.pythonhosted.org/packages/d6/7c/8313d981d66f2ff20e8277c1b410ecd1a12354726ce0890956ad0f278451/bitarray-3.7.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ac56caf0d0d00815444d18560d97ae3f1130e14e250438b02354c7cdcaa6eec3", size = 146881, upload-time = "2025-08-24T18:54:47.923Z" }, -] - -[[package]] -name = "bitstring" -version = "4.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "bitarray" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/15/a8/a80c890db75d5bdd5314b5de02c4144c7de94fd0cefcae51acaeb14c6a3f/bitstring-4.3.1.tar.gz", hash = "sha256:a08bc09d3857216d4c0f412a1611056f1cc2b64fd254fb1e8a0afba7cfa1a95a", size = 251426, upload-time = "2025-03-22T09:39:06.978Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/2d/174566b533755ddf8efb32a5503af61c756a983de379f8ad3aed6a982d38/bitstring-4.3.1-py3-none-any.whl", hash = "sha256:69d1587f0ac18dc7d93fc7e80d5f447161a33e57027e726dc18a0a8bacf1711a", size = 71930, upload-time = "2025-03-22T09:39:05.163Z" }, -] - [[package]] name = "black" version = "25.1.0" @@ -341,22 +69,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, ] -[[package]] -name = "bolt11" -version = "2.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "base58" }, - { name = "bech32" }, - { name = "bitstring" }, - { name = "click" }, - { name = "coincurve" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/16/39/4b151129bac9a5a7bce390531659de760b065c679d6a47b20f9fa034f4e1/bolt11-2.1.1.tar.gz", hash = "sha256:4e903d77208bfc4de8fc7e183a0689ea54afe874c91d62524d3b8c09492fa7ea", size = 13872, upload-time = "2025-03-12T13:33:09.668Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/9e/78e59887cbf94116bdc890af7726ae264d55df14f1c777724c656e8a35fe/bolt11-2.1.1-py3-none-any.whl", hash = "sha256:fd4edb9e73e27bf5e017f47c97f7c6827b523fcf9cab152b123961ca78323e2d", size = 17102, upload-time = "2025-03-12T13:33:08.142Z" }, -] - [[package]] name = "certifi" version = "2025.8.3" @@ -421,48 +133,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, ] -[[package]] -name = "charset-normalizer" -version = "3.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, - { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, - { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, - { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, - { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, - { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, - { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, - { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, - { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, - { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, - { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, - { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, - { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, - { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, - { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, - { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, - { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, - { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, - { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, - { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, - { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, - { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, - { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, - { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, - { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, -] - [[package]] name = "click" version = "8.2.1" @@ -564,18 +234,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/38/2c8dae407d301eaf942e377a5b2b30485cfa0df03c6c2dcc2ac044054ed9/cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d", size = 2801764, upload-time = "2024-06-04T19:54:25.455Z" }, ] -[[package]] -name = "deprecated" -version = "1.2.18" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744, upload-time = "2025-01-27T10:46:25.7Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload-time = "2025-01-27T10:46:09.186Z" }, -] - [[package]] name = "distlib" version = "0.4.0" @@ -606,45 +264,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, ] -[[package]] -name = "email-validator" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "dnspython" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967, upload-time = "2024-06-20T11:30:30.034Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload-time = "2024-06-20T11:30:28.248Z" }, -] - [[package]] name = "embit" version = "0.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/83/88/b054b00ade6d2a41749e15976cdcec4b7ec4656ac1cb917ce3de395528d1/embit-0.8.0.tar.gz", hash = "sha256:8bf4b10073c67400370ce523fb16f035fe759f6fdd987c579bdcc268d75ed770", size = 763120, upload-time = "2024-05-30T10:56:48.944Z" } -[[package]] -name = "environs" -version = "14.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "marshmallow" }, - { name = "python-dotenv" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2e/eb/805549ab44db6a9a8206973e8283b49734b903f85d3fed549db78b0817fc/environs-14.2.0.tar.gz", hash = "sha256:2b6c78a77dfefb57ca30d43a232270ecc82adabf67ab318e018084b9a3529e9b", size = 32317, upload-time = "2025-05-22T19:25:01.614Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/75/e309b90c6f95a6e01aa86425ff567d3c634eea33bde915f3ceb910092461/environs-14.2.0-py3-none-any.whl", hash = "sha256:22669a58d53c5b86a25d0231c4a41a6ebeb82d3942b8fbd9cf645890c92a1843", size = 15733, upload-time = "2025-05-22T19:24:59.666Z" }, -] - [[package]] name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -653,32 +284,16 @@ wheels = [ [[package]] name = "fastapi" -version = "0.115.13" +version = "0.116.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/64/ec0788201b5554e2a87c49af26b77a4d132f807a0fa9675257ac92c6aa0e/fastapi-0.115.13.tar.gz", hash = "sha256:55d1d25c2e1e0a0a50aceb1c8705cd932def273c102bff0b1c1da88b3c6eb307", size = 295680, upload-time = "2025-06-17T11:49:45.575Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/4a/e17764385382062b0edbb35a26b7cf76d71e27e456546277a42ba6545c6e/fastapi-0.115.13-py3-none-any.whl", hash = "sha256:0a0cab59afa7bab22f5eb347f8c9864b681558c278395e94035a741fc10cd865", size = 95315, upload-time = "2025-06-17T11:49:44.106Z" }, -] - -[[package]] -name = "fastapi-sso" -version = "0.18.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "fastapi" }, - { name = "httpx" }, - { name = "oauthlib" }, - { name = "pydantic", extra = ["email"] }, - { name = "pyjwt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d2/57/cc971c018af5d09eb5f8d1cd12abdd99ab4c59ea5c0b0b1b96349ffe117d/fastapi_sso-0.18.0.tar.gz", hash = "sha256:d8df5a686af7a6a7be248817544b405cf77f7e9ffcd5d0d7d2a196fd071964bc", size = 16811, upload-time = "2025-03-20T17:09:09.958Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/03/70ca13994f5569d343a9f99dba2930c8ae3471171f161b8887d44b6c526f/fastapi_sso-0.18.0-py3-none-any.whl", hash = "sha256:727754ad770b70690f1471f7b0a9e17c6dfd8ebd6e477616d3bde1eaf62e53dc", size = 26103, upload-time = "2025-03-20T17:09:08.656Z" }, + { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" }, ] [[package]] @@ -690,145 +305,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, ] -[[package]] -name = "filetype" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, -] - -[[package]] -name = "frozenlist" -version = "1.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/36/0da0a49409f6b47cc2d060dc8c9040b897b5902a8a4e37d9bc1deb11f680/frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a", size = 81304, upload-time = "2025-06-09T22:59:46.226Z" }, - { url = "https://files.pythonhosted.org/packages/77/f0/77c11d13d39513b298e267b22eb6cb559c103d56f155aa9a49097221f0b6/frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61", size = 47735, upload-time = "2025-06-09T22:59:48.133Z" }, - { url = "https://files.pythonhosted.org/packages/37/12/9d07fa18971a44150593de56b2f2947c46604819976784bcf6ea0d5db43b/frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d", size = 46775, upload-time = "2025-06-09T22:59:49.564Z" }, - { url = "https://files.pythonhosted.org/packages/70/34/f73539227e06288fcd1f8a76853e755b2b48bca6747e99e283111c18bcd4/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e", size = 224644, upload-time = "2025-06-09T22:59:51.35Z" }, - { url = "https://files.pythonhosted.org/packages/fb/68/c1d9c2f4a6e438e14613bad0f2973567586610cc22dcb1e1241da71de9d3/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9", size = 222125, upload-time = "2025-06-09T22:59:52.884Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d0/98e8f9a515228d708344d7c6986752be3e3192d1795f748c24bcf154ad99/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c", size = 233455, upload-time = "2025-06-09T22:59:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/79/df/8a11bcec5600557f40338407d3e5bea80376ed1c01a6c0910fcfdc4b8993/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981", size = 227339, upload-time = "2025-06-09T22:59:56.187Z" }, - { url = "https://files.pythonhosted.org/packages/50/82/41cb97d9c9a5ff94438c63cc343eb7980dac4187eb625a51bdfdb7707314/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615", size = 212969, upload-time = "2025-06-09T22:59:57.604Z" }, - { url = "https://files.pythonhosted.org/packages/13/47/f9179ee5ee4f55629e4f28c660b3fdf2775c8bfde8f9c53f2de2d93f52a9/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50", size = 222862, upload-time = "2025-06-09T22:59:59.498Z" }, - { url = "https://files.pythonhosted.org/packages/1a/52/df81e41ec6b953902c8b7e3a83bee48b195cb0e5ec2eabae5d8330c78038/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa", size = 222492, upload-time = "2025-06-09T23:00:01.026Z" }, - { url = "https://files.pythonhosted.org/packages/84/17/30d6ea87fa95a9408245a948604b82c1a4b8b3e153cea596421a2aef2754/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577", size = 238250, upload-time = "2025-06-09T23:00:03.401Z" }, - { url = "https://files.pythonhosted.org/packages/8f/00/ecbeb51669e3c3df76cf2ddd66ae3e48345ec213a55e3887d216eb4fbab3/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59", size = 218720, upload-time = "2025-06-09T23:00:05.282Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c0/c224ce0e0eb31cc57f67742071bb470ba8246623c1823a7530be0e76164c/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e", size = 232585, upload-time = "2025-06-09T23:00:07.962Z" }, - { url = "https://files.pythonhosted.org/packages/55/3c/34cb694abf532f31f365106deebdeac9e45c19304d83cf7d51ebbb4ca4d1/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd", size = 234248, upload-time = "2025-06-09T23:00:09.428Z" }, - { url = "https://files.pythonhosted.org/packages/98/c0/2052d8b6cecda2e70bd81299e3512fa332abb6dcd2969b9c80dfcdddbf75/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718", size = 221621, upload-time = "2025-06-09T23:00:11.32Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bf/7dcebae315436903b1d98ffb791a09d674c88480c158aa171958a3ac07f0/frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e", size = 39578, upload-time = "2025-06-09T23:00:13.526Z" }, - { url = "https://files.pythonhosted.org/packages/8f/5f/f69818f017fa9a3d24d1ae39763e29b7f60a59e46d5f91b9c6b21622f4cd/frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464", size = 43830, upload-time = "2025-06-09T23:00:14.98Z" }, - { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251, upload-time = "2025-06-09T23:00:16.279Z" }, - { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183, upload-time = "2025-06-09T23:00:17.698Z" }, - { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107, upload-time = "2025-06-09T23:00:18.952Z" }, - { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333, upload-time = "2025-06-09T23:00:20.275Z" }, - { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724, upload-time = "2025-06-09T23:00:21.705Z" }, - { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842, upload-time = "2025-06-09T23:00:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767, upload-time = "2025-06-09T23:00:25.103Z" }, - { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130, upload-time = "2025-06-09T23:00:27.061Z" }, - { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301, upload-time = "2025-06-09T23:00:29.02Z" }, - { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606, upload-time = "2025-06-09T23:00:30.514Z" }, - { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372, upload-time = "2025-06-09T23:00:31.966Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860, upload-time = "2025-06-09T23:00:33.375Z" }, - { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893, upload-time = "2025-06-09T23:00:35.002Z" }, - { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323, upload-time = "2025-06-09T23:00:36.468Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149, upload-time = "2025-06-09T23:00:37.963Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565, upload-time = "2025-06-09T23:00:39.753Z" }, - { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019, upload-time = "2025-06-09T23:00:40.988Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, - { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, - { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, - { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, - { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, - { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, - { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, - { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, - { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, - { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, - { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, - { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, - { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, - { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, - { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, - { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, -] - -[[package]] -name = "greenlet" -version = "3.2.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/ed/6bfa4109fcb23a58819600392564fea69cdc6551ffd5e69ccf1d52a40cbc/greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c", size = 271061, upload-time = "2025-08-07T13:17:15.373Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fc/102ec1a2fc015b3a7652abab7acf3541d58c04d3d17a8d3d6a44adae1eb1/greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590", size = 629475, upload-time = "2025-08-07T13:42:54.009Z" }, - { url = "https://files.pythonhosted.org/packages/c5/26/80383131d55a4ac0fb08d71660fd77e7660b9db6bdb4e8884f46d9f2cc04/greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c", size = 640802, upload-time = "2025-08-07T13:45:25.52Z" }, - { url = "https://files.pythonhosted.org/packages/9f/7c/e7833dbcd8f376f3326bd728c845d31dcde4c84268d3921afcae77d90d08/greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b", size = 636703, upload-time = "2025-08-07T13:53:12.622Z" }, - { url = "https://files.pythonhosted.org/packages/e9/49/547b93b7c0428ede7b3f309bc965986874759f7d89e4e04aeddbc9699acb/greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31", size = 635417, upload-time = "2025-08-07T13:18:25.189Z" }, - { url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" }, - { url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" }, - { url = "https://files.pythonhosted.org/packages/a1/8d/88f3ebd2bc96bf7747093696f4335a0a8a4c5acfcf1b757717c0d2474ba3/greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", size = 1137126, upload-time = "2025-08-07T13:18:20.239Z" }, - { url = "https://files.pythonhosted.org/packages/d6/6f/b60b0291d9623c496638c582297ead61f43c4b72eef5e9c926ef4565ec13/greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", size = 298654, upload-time = "2025-08-07T13:50:00.469Z" }, - { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, - { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" }, - { url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" }, - { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, - { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, - { url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" }, - { url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" }, - { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, - { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, - { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, - { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, - { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, - { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, -] - -[[package]] -name = "grpcio" -version = "1.69.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/87/06a145284cbe86c91ca517fe6b57be5efbb733c0d6374b407f0992054d18/grpcio-1.69.0.tar.gz", hash = "sha256:936fa44241b5379c5afc344e1260d467bee495747eaf478de825bab2791da6f5", size = 12738244, upload-time = "2025-01-05T05:53:20.27Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/6e/2f8ee5fb65aef962d0bd7e46b815e7b52820687e29c138eaee207a688abc/grpcio-1.69.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:2060ca95a8db295ae828d0fc1c7f38fb26ccd5edf9aa51a0f44251f5da332e97", size = 5190753, upload-time = "2025-01-05T05:45:05.892Z" }, - { url = "https://files.pythonhosted.org/packages/89/07/028dcda44d40f9488f0a0de79c5ffc80e2c1bc5ed89da9483932e3ea67cf/grpcio-1.69.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:2e52e107261fd8fa8fa457fe44bfadb904ae869d87c1280bf60f93ecd3e79278", size = 11096752, upload-time = "2025-01-05T05:45:11.517Z" }, - { url = "https://files.pythonhosted.org/packages/99/a0/c727041b1410605ba38b585b6b52c1a289d7fcd70a41bccbc2c58fc643b2/grpcio-1.69.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:316463c0832d5fcdb5e35ff2826d9aa3f26758d29cdfb59a368c1d6c39615a11", size = 5705442, upload-time = "2025-01-05T05:45:18.828Z" }, - { url = "https://files.pythonhosted.org/packages/7a/2f/1c53f5d127ff882443b19c757d087da1908f41c58c4b098e8eaf6b2bb70a/grpcio-1.69.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26c9a9c4ac917efab4704b18eed9082ed3b6ad19595f047e8173b5182fec0d5e", size = 6333796, upload-time = "2025-01-05T05:45:23.431Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f6/2017da2a1b64e896af710253e5bfbb4188605cdc18bce3930dae5cdbf502/grpcio-1.69.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90b3646ced2eae3a0599658eeccc5ba7f303bf51b82514c50715bdd2b109e5ec", size = 5954245, upload-time = "2025-01-05T05:45:27.374Z" }, - { url = "https://files.pythonhosted.org/packages/c1/65/1395bec928e99ba600464fb01b541e7e4cdd462e6db25259d755ef9f8d02/grpcio-1.69.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3b75aea7c6cb91b341c85e7c1d9db1e09e1dd630b0717f836be94971e015031e", size = 6664854, upload-time = "2025-01-05T05:45:32.031Z" }, - { url = "https://files.pythonhosted.org/packages/40/57/8b3389cfeb92056c8b44288c9c4ed1d331bcad0215c4eea9ae4629e156d9/grpcio-1.69.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5cfd14175f9db33d4b74d63de87c64bb0ee29ce475ce3c00c01ad2a3dc2a9e51", size = 6226854, upload-time = "2025-01-05T05:45:36.915Z" }, - { url = "https://files.pythonhosted.org/packages/cc/61/1f2bbeb7c15544dffc98b3f65c093e746019995e6f1e21dc3655eec3dc23/grpcio-1.69.0-cp310-cp310-win32.whl", hash = "sha256:9031069d36cb949205293cf0e243abd5e64d6c93e01b078c37921493a41b72dc", size = 3662734, upload-time = "2025-01-05T05:45:40.798Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ba/bf1a6d9f5c17d2da849793d72039776c56c98c889c9527f6721b6ee57e6e/grpcio-1.69.0-cp310-cp310-win_amd64.whl", hash = "sha256:cc89b6c29f3dccbe12d7a3b3f1b3999db4882ae076c1c1f6df231d55dbd767a5", size = 4410306, upload-time = "2025-01-05T05:45:45.299Z" }, - { url = "https://files.pythonhosted.org/packages/8d/cd/ca256aeef64047881586331347cd5a68a4574ba1a236e293cd8eba34e355/grpcio-1.69.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:8de1b192c29b8ce45ee26a700044717bcbbd21c697fa1124d440548964328561", size = 5198734, upload-time = "2025-01-05T05:45:49.29Z" }, - { url = "https://files.pythonhosted.org/packages/37/3f/10c1e5e0150bf59aa08ea6aebf38f87622f95f7f33f98954b43d1b2a3200/grpcio-1.69.0-cp311-cp311-macosx_10_14_universal2.whl", hash = "sha256:7e76accf38808f5c5c752b0ab3fd919eb14ff8fafb8db520ad1cc12afff74de6", size = 11135285, upload-time = "2025-01-05T05:45:53.724Z" }, - { url = "https://files.pythonhosted.org/packages/08/61/61cd116a572203a740684fcba3fef37a3524f1cf032b6568e1e639e59db0/grpcio-1.69.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:d5658c3c2660417d82db51e168b277e0ff036d0b0f859fa7576c0ffd2aec1442", size = 5699468, upload-time = "2025-01-05T05:45:58.69Z" }, - { url = "https://files.pythonhosted.org/packages/01/f1/a841662e8e2465ba171c973b77d18fa7438ced535519b3c53617b7e6e25c/grpcio-1.69.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5494d0e52bf77a2f7eb17c6da662886ca0a731e56c1c85b93505bece8dc6cf4c", size = 6332337, upload-time = "2025-01-05T05:46:05.323Z" }, - { url = "https://files.pythonhosted.org/packages/62/b1/c30e932e02c2e0bfdb8df46fe3b0c47f518fb04158ebdc0eb96cc97d642f/grpcio-1.69.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ed866f9edb574fd9be71bf64c954ce1b88fc93b2a4cbf94af221e9426eb14d6", size = 5949844, upload-time = "2025-01-05T05:46:09.727Z" }, - { url = "https://files.pythonhosted.org/packages/5e/cb/55327d43b6286100ffae7d1791be6178d13c917382f3e9f43f82e8b393cf/grpcio-1.69.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c5ba38aeac7a2fe353615c6b4213d1fbb3a3c34f86b4aaa8be08baaaee8cc56d", size = 6661828, upload-time = "2025-01-05T05:46:14.937Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e4/120d72ae982d51cb9cabcd9672f8a1c6d62011b493a4d049d2abdf564db0/grpcio-1.69.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f79e05f5bbf551c4057c227d1b041ace0e78462ac8128e2ad39ec58a382536d2", size = 6226026, upload-time = "2025-01-05T05:46:17.465Z" }, - { url = "https://files.pythonhosted.org/packages/96/e8/2cc15f11db506d7b1778f0587fa7bdd781602b05b3c4d75b7ca13de33d62/grpcio-1.69.0-cp311-cp311-win32.whl", hash = "sha256:bf1f8be0da3fcdb2c1e9f374f3c2d043d606d69f425cd685110dd6d0d2d61258", size = 3662653, upload-time = "2025-01-05T05:46:19.797Z" }, - { url = "https://files.pythonhosted.org/packages/42/78/3c5216829a48237fcb71a077f891328a435e980d9757a9ebc49114d88768/grpcio-1.69.0-cp311-cp311-win_amd64.whl", hash = "sha256:fb9302afc3a0e4ba0b225cd651ef8e478bf0070cf11a529175caecd5ea2474e7", size = 4412824, upload-time = "2025-01-05T05:46:22.421Z" }, - { url = "https://files.pythonhosted.org/packages/61/1d/8f28f147d7f3f5d6b6082f14e1e0f40d58e50bc2bd30d2377c730c57a286/grpcio-1.69.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:fc18a4de8c33491ad6f70022af5c460b39611e39578a4d84de0fe92f12d5d47b", size = 5161414, upload-time = "2025-01-05T05:46:27.03Z" }, - { url = "https://files.pythonhosted.org/packages/35/4b/9ab8ea65e515e1844feced1ef9e7a5d8359c48d986c93f3d2a2006fbdb63/grpcio-1.69.0-cp312-cp312-macosx_10_14_universal2.whl", hash = "sha256:0f0270bd9ffbff6961fe1da487bdcd594407ad390cc7960e738725d4807b18c4", size = 11108909, upload-time = "2025-01-05T05:46:31.986Z" }, - { url = "https://files.pythonhosted.org/packages/99/68/1856fde2b3c3162bdfb9845978608deef3606e6907fdc2c87443fce6ecd0/grpcio-1.69.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:dc48f99cc05e0698e689b51a05933253c69a8c8559a47f605cff83801b03af0e", size = 5658302, upload-time = "2025-01-05T05:46:37.05Z" }, - { url = "https://files.pythonhosted.org/packages/3e/21/3fa78d38dc5080d0d677103fad3a8cd55091635cc2069a7c06c7a54e6c4d/grpcio-1.69.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e925954b18d41aeb5ae250262116d0970893b38232689c4240024e4333ac084", size = 6306201, upload-time = "2025-01-05T05:46:41.138Z" }, - { url = "https://files.pythonhosted.org/packages/f3/cb/5c47b82fd1baf43dba973ae399095d51aaf0085ab0439838b4cbb1e87e3c/grpcio-1.69.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87d222569273720366f68a99cb62e6194681eb763ee1d3b1005840678d4884f9", size = 5919649, upload-time = "2025-01-05T05:46:45.366Z" }, - { url = "https://files.pythonhosted.org/packages/c6/67/59d1a56a0f9508a29ea03e1ce800bdfacc1f32b4f6b15274b2e057bf8758/grpcio-1.69.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b62b0f41e6e01a3e5082000b612064c87c93a49b05f7602fe1b7aa9fd5171a1d", size = 6648974, upload-time = "2025-01-05T05:46:48.208Z" }, - { url = "https://files.pythonhosted.org/packages/f8/fe/ca70c14d98c6400095f19a0f4df8273d09c2106189751b564b26019f1dbe/grpcio-1.69.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:db6f9fd2578dbe37db4b2994c94a1d9c93552ed77dca80e1657bb8a05b898b55", size = 6215144, upload-time = "2025-01-05T05:46:50.891Z" }, - { url = "https://files.pythonhosted.org/packages/b3/94/b2b0a9fd487fc8262e20e6dd0ec90d9fa462c82a43b4855285620f6e9d01/grpcio-1.69.0-cp312-cp312-win32.whl", hash = "sha256:b192b81076073ed46f4b4dd612b8897d9a1e39d4eabd822e5da7b38497ed77e1", size = 3644552, upload-time = "2025-01-05T05:46:55.811Z" }, - { url = "https://files.pythonhosted.org/packages/93/99/81aec9f85412e3255a591ae2ccb799238e074be774e5f741abae08a23418/grpcio-1.69.0-cp312-cp312-win_amd64.whl", hash = "sha256:1227ff7836f7b3a4ab04e5754f1d001fa52a730685d3dc894ed8bc262cc96c01", size = 4399532, upload-time = "2025-01-05T05:46:58.348Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -838,15 +314,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "http-ece" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7c/af/249d1576653b69c20b9ac30e284b63bd94af6a175d72d87813235caf2482/http_ece-1.2.1.tar.gz", hash = "sha256:8c6ab23116bbf6affda894acfd5f2ca0fb8facbcbb72121c11c75c33e7ce8cff", size = 8830, upload-time = "2024-08-08T00:10:47.301Z" } - [[package]] name = "httpcore" version = "1.0.9" @@ -904,533 +371,115 @@ wheels = [ ] [[package]] -name = "itsdangerous" -version = "2.2.0" +name = "mnemonic" +version = "0.21" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/77/e6232ed59fbd7b90208bb8d4f89ed5aabcf30a524bc2fb8f0dafbe8e7df9/mnemonic-0.21.tar.gz", hash = "sha256:1fe496356820984f45559b1540c80ff10de448368929b9c60a2b55744cc88acf", size = 152462, upload-time = "2024-01-05T10:46:14.895Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, + { url = "https://files.pythonhosted.org/packages/57/48/5abb16ce7f9d97b728e6b97c704ceaa614362e0847651f379ed0511942a0/mnemonic-0.21-py3-none-any.whl", hash = "sha256:72dc9de16ec5ef47287237b9b6943da11647a03fe7cf1f139fc3d7c4a7439288", size = 92701, upload-time = "2024-01-05T10:46:12.703Z" }, ] [[package]] -name = "jinja2" -version = "3.1.6" +name = "mypy" +version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/ea637422dedf0bf36f3ef238eab4e455e2a0dcc3082b5cc067615347ab8e/mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01", size = 3352570, upload-time = "2025-07-31T07:54:19.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, + { url = "https://files.pythonhosted.org/packages/77/a9/3d7aa83955617cdf02f94e50aab5c830d205cfa4320cf124ff64acce3a8e/mypy-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3fbe6d5555bf608c47203baa3e72dbc6ec9965b3d7c318aa9a4ca76f465bd972", size = 11003299, upload-time = "2025-07-31T07:54:06.425Z" }, + { url = "https://files.pythonhosted.org/packages/83/e8/72e62ff837dd5caaac2b4a5c07ce769c8e808a00a65e5d8f94ea9c6f20ab/mypy-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80ef5c058b7bce08c83cac668158cb7edea692e458d21098c7d3bce35a5d43e7", size = 10125451, upload-time = "2025-07-31T07:53:52.974Z" }, + { url = "https://files.pythonhosted.org/packages/7d/10/f3f3543f6448db11881776f26a0ed079865926b0c841818ee22de2c6bbab/mypy-1.17.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a580f8a70c69e4a75587bd925d298434057fe2a428faaf927ffe6e4b9a98df", size = 11916211, upload-time = "2025-07-31T07:53:18.879Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/63e83ed551282d67bb3f7fea2cd5561b08d2bb6eb287c096539feb5ddbc5/mypy-1.17.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd86bb649299f09d987a2eebb4d52d10603224500792e1bee18303bbcc1ce390", size = 12652687, upload-time = "2025-07-31T07:53:30.544Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/68f2eeef11facf597143e85b694a161868b3b006a5fbad50e09ea117ef24/mypy-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a76906f26bd8d51ea9504966a9c25419f2e668f012e0bdf3da4ea1526c534d94", size = 12896322, upload-time = "2025-07-31T07:53:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/a3/87/8e3e9c2c8bd0d7e071a89c71be28ad088aaecbadf0454f46a540bda7bca6/mypy-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:e79311f2d904ccb59787477b7bd5d26f3347789c06fcd7656fa500875290264b", size = 9507962, upload-time = "2025-07-31T07:53:08.431Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/eadc80c4e0a70db1c08921dcc220357ba8ab2faecb4392e3cebeb10edbfa/mypy-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad37544be07c5d7fba814eb370e006df58fed8ad1ef33ed1649cb1889ba6ff58", size = 10921009, upload-time = "2025-07-31T07:53:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c1/c869d8c067829ad30d9bdae051046561552516cfb3a14f7f0347b7d973ee/mypy-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:064e2ff508e5464b4bd807a7c1625bc5047c5022b85c70f030680e18f37273a5", size = 10047482, upload-time = "2025-07-31T07:53:26.151Z" }, + { url = "https://files.pythonhosted.org/packages/98/b9/803672bab3fe03cee2e14786ca056efda4bb511ea02dadcedde6176d06d0/mypy-1.17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70401bbabd2fa1aa7c43bb358f54037baf0586f41e83b0ae67dd0534fc64edfd", size = 11832883, upload-time = "2025-07-31T07:53:47.948Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/fcdac695beca66800918c18697b48833a9a6701de288452b6715a98cfee1/mypy-1.17.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92bdc656b7757c438660f775f872a669b8ff374edc4d18277d86b63edba6b8b", size = 12566215, upload-time = "2025-07-31T07:54:04.031Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/a932da3d3dace99ee8eb2043b6ab03b6768c36eb29a02f98f46c18c0da0e/mypy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c1fdf4abb29ed1cb091cf432979e162c208a5ac676ce35010373ff29247bcad5", size = 12751956, upload-time = "2025-07-31T07:53:36.263Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/6438a429e0f2f5cab8bc83e53dbebfa666476f40ee322e13cac5e64b79e7/mypy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:ff2933428516ab63f961644bc49bc4cbe42bbffb2cd3b71cc7277c07d16b1a8b", size = 9507307, upload-time = "2025-07-31T07:53:59.734Z" }, + { url = "https://files.pythonhosted.org/packages/17/a2/7034d0d61af8098ec47902108553122baa0f438df8a713be860f7407c9e6/mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb", size = 11086295, upload-time = "2025-07-31T07:53:28.124Z" }, + { url = "https://files.pythonhosted.org/packages/14/1f/19e7e44b594d4b12f6ba8064dbe136505cec813549ca3e5191e40b1d3cc2/mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403", size = 10112355, upload-time = "2025-07-31T07:53:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/5b/69/baa33927e29e6b4c55d798a9d44db5d394072eef2bdc18c3e2048c9ed1e9/mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056", size = 11875285, upload-time = "2025-07-31T07:53:55.293Z" }, + { url = "https://files.pythonhosted.org/packages/90/13/f3a89c76b0a41e19490b01e7069713a30949d9a6c147289ee1521bcea245/mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341", size = 12737895, upload-time = "2025-07-31T07:53:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/c4ee79ac484241301564072e6476c5a5be2590bc2e7bfd28220033d2ef8f/mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb", size = 12931025, upload-time = "2025-07-31T07:54:17.125Z" }, + { url = "https://files.pythonhosted.org/packages/89/b8/7409477be7919a0608900e6320b155c72caab4fef46427c5cc75f85edadd/mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19", size = 9584664, upload-time = "2025-07-31T07:54:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f3/8fcd2af0f5b806f6cf463efaffd3c9548a28f84220493ecd38d127b6b66d/mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9", size = 2283411, upload-time = "2025-07-31T07:53:24.664Z" }, ] [[package]] -name = "jsonpath-ng" -version = "1.7.0" +name = "mypy-extensions" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ply" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6d/86/08646239a313f895186ff0a4573452038eed8c86f54380b3ebac34d32fb2/jsonpath-ng-1.7.0.tar.gz", hash = "sha256:f6f5f7fd4e5ff79c785f1573b394043b39849fb2bb47bcead935d12b00beab3c", size = 37838, upload-time = "2024-10-11T15:41:42.404Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/5a/73ecb3d82f8615f32ccdadeb9356726d6cae3a4bbc840b437ceb95708063/jsonpath_ng-1.7.0-py3-none-any.whl", hash = "sha256:f3d7f9e848cba1b6da28c55b1c26ff915dc9e0b1ba7e752a53d6da8d5cbd00b6", size = 30105, upload-time = "2024-11-20T17:58:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] -name = "limits" -version = "5.5.0" +name = "nodeenv" +version = "1.9.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "deprecated" }, - { name = "packaging" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/17/7a2e9378c8b8bd4efe3573fd18d2793ad2a37051af5ccce94550a4e5d62d/limits-5.5.0.tar.gz", hash = "sha256:ee269fedb078a904608b264424d9ef4ab10555acc8d090b6fc1db70e913327ea", size = 95514, upload-time = "2025-08-05T18:23:54.771Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/68/ee314018c28da75ece5a639898b4745bd0687c0487fc465811f0c4b9cd44/limits-5.5.0-py3-none-any.whl", hash = "sha256:57217d01ffa5114f7e233d1f5e5bdc6fe60c9b24ade387bf4d5e83c5cf929bae", size = 60948, upload-time = "2025-08-05T18:23:53.335Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] [[package]] -name = "lnbits" -version = "1.2.1" +name = "packaging" +version = "25.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiosqlite" }, - { name = "asyncpg" }, - { name = "bech32" }, - { name = "bolt11" }, - { name = "click" }, - { name = "ecdsa" }, - { name = "embit" }, - { name = "environs" }, - { name = "fastapi" }, - { name = "fastapi-sso" }, - { name = "filetype" }, - { name = "grpcio" }, - { name = "httpx" }, - { name = "itsdangerous" }, - { name = "jinja2" }, - { name = "jsonpath-ng" }, - { name = "lnurl" }, - { name = "loguru" }, - { name = "nostr-sdk" }, - { name = "packaging" }, - { name = "passlib" }, - { name = "protobuf" }, - { name = "pycryptodomex" }, - { name = "pydantic" }, - { name = "pyjwt" }, - { name = "pyln-client" }, - { name = "pynostr" }, - { name = "pyqrcode" }, - { name = "python-crontab" }, - { name = "python-multipart" }, - { name = "pywebpush" }, - { name = "secp256k1" }, - { name = "shortuuid" }, - { name = "slowapi" }, - { name = "sqlalchemy" }, - { name = "sse-starlette" }, - { name = "typing-extensions" }, - { name = "uvicorn" }, - { name = "uvloop" }, - { name = "websocket-client" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d2/20/29ab04b4da57b41ae71c1f9a6de36d7a68d2db312887e8acb868b71b6649/lnbits-1.2.1.tar.gz", hash = "sha256:7770e90c7072657fb759f4615962a2dd05d064851f58343695a7dd922a6bc20b", size = 3060543, upload-time = "2025-07-11T10:41:22.902Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/be/24966f6bb3a9c62cfda0a22521d458c5b0fe85e17f39e40389bafedae3ca/lnbits-1.2.1-py3-none-any.whl", hash = "sha256:64d5b8dc0fe5b72f811e13ada1677b09f55f1070d3da901dc26102f47a07563b", size = 3213306, upload-time = "2025-07-11T10:41:20.773Z" }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] -name = "lnbits-example" -version = "0.0.0" -source = { virtual = "." } -dependencies = [ - { name = "lnbits" }, -] - -[package.dev-dependencies] -dev = [ - { name = "black" }, - { name = "mypy" }, - { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [{ name = "lnbits", specifier = ">1" }] - -[package.metadata.requires-dev] -dev = [ - { name = "black" }, - { name = "mypy" }, - { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "ruff" }, +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] [[package]] -name = "lnurl" -version = "0.5.3" +name = "platformdirs" +version = "4.3.8" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "bech32" }, - { name = "bolt11" }, - { name = "ecdsa" }, - { name = "httpx" }, - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/86/7cf41c5a441abd6c513c80edc3405f33032188fa3490c8f614f64c69493d/lnurl-0.5.3.tar.gz", hash = "sha256:60154bcfdbb98fb143eeca970a16d73a582f28e057a826b5f222259411c497fe", size = 13863, upload-time = "2024-09-03T09:38:48.799Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/36/7eaa8190bafa7810970644861299c0f759600a2c42eb7adc663fc304d4a1/lnurl-0.5.3-py3-none-any.whl", hash = "sha256:feaf6c60b0b7f104894ef3accbd30d23d52e038c2797c58432baea7f4a8aa952", size = 13672, upload-time = "2024-09-03T09:38:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, ] [[package]] -name = "loguru" -version = "0.7.3" +name = "pluggy" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "win32-setctime", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] -name = "markdown-it-py" -version = "4.0.0" +name = "pre-commit" +version = "4.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl" }, + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload-time = "2024-10-18T15:20:51.44Z" }, - { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload-time = "2024-10-18T15:20:52.426Z" }, - { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload-time = "2024-10-18T15:20:53.578Z" }, - { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload-time = "2024-10-18T15:20:55.06Z" }, - { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload-time = "2024-10-18T15:20:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload-time = "2024-10-18T15:20:57.189Z" }, - { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload-time = "2024-10-18T15:20:58.235Z" }, - { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload-time = "2024-10-18T15:20:59.235Z" }, - { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload-time = "2024-10-18T15:21:00.307Z" }, - { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload-time = "2024-10-18T15:21:01.122Z" }, - { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" }, - { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" }, - { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" }, - { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" }, - { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" }, - { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" }, - { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, - { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, -] - -[[package]] -name = "marshmallow" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backports-datetime-fromisoformat", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1e/ff/26df5a9f5ac57ccf693a5854916ab47243039d2aa9e0fe5f5a0331e7b74b/marshmallow-4.0.0.tar.gz", hash = "sha256:3b6e80aac299a7935cfb97ed01d1854fb90b5079430969af92118ea1b12a8d55", size = 220507, upload-time = "2025-04-17T02:25:54.925Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/26/6cc45d156f44dbe1d5696d9e54042e4dcaf7b946c0b86df6a97d29706f32/marshmallow-4.0.0-py3-none-any.whl", hash = "sha256:e7b0528337e9990fd64950f8a6b3a1baabed09ad17a0dfb844d701151f92d203", size = 48420, upload-time = "2025-04-17T02:25:53.375Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "multidict" -version = "6.6.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/6b/86f353088c1358e76fd30b0146947fddecee812703b604ee901e85cd2a80/multidict-6.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b8aa6f0bd8125ddd04a6593437bad6a7e70f300ff4180a531654aa2ab3f6d58f", size = 77054, upload-time = "2025-08-11T12:06:02.99Z" }, - { url = "https://files.pythonhosted.org/packages/19/5d/c01dc3d3788bb877bd7f5753ea6eb23c1beeca8044902a8f5bfb54430f63/multidict-6.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b9e5853bbd7264baca42ffc53391b490d65fe62849bf2c690fa3f6273dbcd0cb", size = 44914, upload-time = "2025-08-11T12:06:05.264Z" }, - { url = "https://files.pythonhosted.org/packages/46/44/964dae19ea42f7d3e166474d8205f14bb811020e28bc423d46123ddda763/multidict-6.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0af5f9dee472371e36d6ae38bde009bd8ce65ac7335f55dcc240379d7bed1495", size = 44601, upload-time = "2025-08-11T12:06:06.627Z" }, - { url = "https://files.pythonhosted.org/packages/31/20/0616348a1dfb36cb2ab33fc9521de1f27235a397bf3f59338e583afadd17/multidict-6.6.4-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:d24f351e4d759f5054b641c81e8291e5d122af0fca5c72454ff77f7cbe492de8", size = 224821, upload-time = "2025-08-11T12:06:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/14/26/5d8923c69c110ff51861af05bd27ca6783011b96725d59ccae6d9daeb627/multidict-6.6.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db6a3810eec08280a172a6cd541ff4a5f6a97b161d93ec94e6c4018917deb6b7", size = 242608, upload-time = "2025-08-11T12:06:09.697Z" }, - { url = "https://files.pythonhosted.org/packages/5c/cc/e2ad3ba9459aa34fa65cf1f82a5c4a820a2ce615aacfb5143b8817f76504/multidict-6.6.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a1b20a9d56b2d81e2ff52ecc0670d583eaabaa55f402e8d16dd062373dbbe796", size = 222324, upload-time = "2025-08-11T12:06:10.905Z" }, - { url = "https://files.pythonhosted.org/packages/19/db/4ed0f65701afbc2cb0c140d2d02928bb0fe38dd044af76e58ad7c54fd21f/multidict-6.6.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c9854df0eaa610a23494c32a6f44a3a550fb398b6b51a56e8c6b9b3689578db", size = 253234, upload-time = "2025-08-11T12:06:12.658Z" }, - { url = "https://files.pythonhosted.org/packages/94/c1/5160c9813269e39ae14b73debb907bfaaa1beee1762da8c4fb95df4764ed/multidict-6.6.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4bb7627fd7a968f41905a4d6343b0d63244a0623f006e9ed989fa2b78f4438a0", size = 251613, upload-time = "2025-08-11T12:06:13.97Z" }, - { url = "https://files.pythonhosted.org/packages/05/a9/48d1bd111fc2f8fb98b2ed7f9a115c55a9355358432a19f53c0b74d8425d/multidict-6.6.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caebafea30ed049c57c673d0b36238b1748683be2593965614d7b0e99125c877", size = 241649, upload-time = "2025-08-11T12:06:15.204Z" }, - { url = "https://files.pythonhosted.org/packages/85/2a/f7d743df0019408768af8a70d2037546a2be7b81fbb65f040d76caafd4c5/multidict-6.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ad887a8250eb47d3ab083d2f98db7f48098d13d42eb7a3b67d8a5c795f224ace", size = 239238, upload-time = "2025-08-11T12:06:16.467Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b8/4f4bb13323c2d647323f7919201493cf48ebe7ded971717bfb0f1a79b6bf/multidict-6.6.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ed8358ae7d94ffb7c397cecb62cbac9578a83ecefc1eba27b9090ee910e2efb6", size = 233517, upload-time = "2025-08-11T12:06:18.107Z" }, - { url = "https://files.pythonhosted.org/packages/33/29/4293c26029ebfbba4f574febd2ed01b6f619cfa0d2e344217d53eef34192/multidict-6.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ecab51ad2462197a4c000b6d5701fc8585b80eecb90583635d7e327b7b6923eb", size = 243122, upload-time = "2025-08-11T12:06:19.361Z" }, - { url = "https://files.pythonhosted.org/packages/20/60/a1c53628168aa22447bfde3a8730096ac28086704a0d8c590f3b63388d0c/multidict-6.6.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c5c97aa666cf70e667dfa5af945424ba1329af5dd988a437efeb3a09430389fb", size = 248992, upload-time = "2025-08-11T12:06:20.661Z" }, - { url = "https://files.pythonhosted.org/packages/a3/3b/55443a0c372f33cae5d9ec37a6a973802884fa0ab3586659b197cf8cc5e9/multidict-6.6.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9a950b7cf54099c1209f455ac5970b1ea81410f2af60ed9eb3c3f14f0bfcf987", size = 243708, upload-time = "2025-08-11T12:06:21.891Z" }, - { url = "https://files.pythonhosted.org/packages/7c/60/a18c6900086769312560b2626b18e8cca22d9e85b1186ba77f4755b11266/multidict-6.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:163c7ea522ea9365a8a57832dea7618e6cbdc3cd75f8c627663587459a4e328f", size = 237498, upload-time = "2025-08-11T12:06:23.206Z" }, - { url = "https://files.pythonhosted.org/packages/11/3d/8bdd8bcaff2951ce2affccca107a404925a2beafedd5aef0b5e4a71120a6/multidict-6.6.4-cp310-cp310-win32.whl", hash = "sha256:17d2cbbfa6ff20821396b25890f155f40c986f9cfbce5667759696d83504954f", size = 41415, upload-time = "2025-08-11T12:06:24.77Z" }, - { url = "https://files.pythonhosted.org/packages/c0/53/cab1ad80356a4cd1b685a254b680167059b433b573e53872fab245e9fc95/multidict-6.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:ce9a40fbe52e57e7edf20113a4eaddfacac0561a0879734e636aa6d4bb5e3fb0", size = 46046, upload-time = "2025-08-11T12:06:25.893Z" }, - { url = "https://files.pythonhosted.org/packages/cf/9a/874212b6f5c1c2d870d0a7adc5bb4cfe9b0624fa15cdf5cf757c0f5087ae/multidict-6.6.4-cp310-cp310-win_arm64.whl", hash = "sha256:01d0959807a451fe9fdd4da3e139cb5b77f7328baf2140feeaf233e1d777b729", size = 43147, upload-time = "2025-08-11T12:06:27.534Z" }, - { url = "https://files.pythonhosted.org/packages/6b/7f/90a7f01e2d005d6653c689039977f6856718c75c5579445effb7e60923d1/multidict-6.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c7a0e9b561e6460484318a7612e725df1145d46b0ef57c6b9866441bf6e27e0c", size = 76472, upload-time = "2025-08-11T12:06:29.006Z" }, - { url = "https://files.pythonhosted.org/packages/54/a3/bed07bc9e2bb302ce752f1dabc69e884cd6a676da44fb0e501b246031fdd/multidict-6.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6bf2f10f70acc7a2446965ffbc726e5fc0b272c97a90b485857e5c70022213eb", size = 44634, upload-time = "2025-08-11T12:06:30.374Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4b/ceeb4f8f33cf81277da464307afeaf164fb0297947642585884f5cad4f28/multidict-6.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66247d72ed62d5dd29752ffc1d3b88f135c6a8de8b5f63b7c14e973ef5bda19e", size = 44282, upload-time = "2025-08-11T12:06:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/03/35/436a5da8702b06866189b69f655ffdb8f70796252a8772a77815f1812679/multidict-6.6.4-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:105245cc6b76f51e408451a844a54e6823bbd5a490ebfe5bdfc79798511ceded", size = 229696, upload-time = "2025-08-11T12:06:33.087Z" }, - { url = "https://files.pythonhosted.org/packages/b6/0e/915160be8fecf1fca35f790c08fb74ca684d752fcba62c11daaf3d92c216/multidict-6.6.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbbc54e58b34c3bae389ef00046be0961f30fef7cb0dd9c7756aee376a4f7683", size = 246665, upload-time = "2025-08-11T12:06:34.448Z" }, - { url = "https://files.pythonhosted.org/packages/08/ee/2f464330acd83f77dcc346f0b1a0eaae10230291450887f96b204b8ac4d3/multidict-6.6.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:56c6b3652f945c9bc3ac6c8178cd93132b8d82dd581fcbc3a00676c51302bc1a", size = 225485, upload-time = "2025-08-11T12:06:35.672Z" }, - { url = "https://files.pythonhosted.org/packages/71/cc/9a117f828b4d7fbaec6adeed2204f211e9caf0a012692a1ee32169f846ae/multidict-6.6.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b95494daf857602eccf4c18ca33337dd2be705bccdb6dddbfc9d513e6addb9d9", size = 257318, upload-time = "2025-08-11T12:06:36.98Z" }, - { url = "https://files.pythonhosted.org/packages/25/77/62752d3dbd70e27fdd68e86626c1ae6bccfebe2bb1f84ae226363e112f5a/multidict-6.6.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5b1413361cef15340ab9dc61523e653d25723e82d488ef7d60a12878227ed50", size = 254689, upload-time = "2025-08-11T12:06:38.233Z" }, - { url = "https://files.pythonhosted.org/packages/00/6e/fac58b1072a6fc59af5e7acb245e8754d3e1f97f4f808a6559951f72a0d4/multidict-6.6.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e167bf899c3d724f9662ef00b4f7fef87a19c22b2fead198a6f68b263618df52", size = 246709, upload-time = "2025-08-11T12:06:39.517Z" }, - { url = "https://files.pythonhosted.org/packages/01/ef/4698d6842ef5e797c6db7744b0081e36fb5de3d00002cc4c58071097fac3/multidict-6.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aaea28ba20a9026dfa77f4b80369e51cb767c61e33a2d4043399c67bd95fb7c6", size = 243185, upload-time = "2025-08-11T12:06:40.796Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c9/d82e95ae1d6e4ef396934e9b0e942dfc428775f9554acf04393cce66b157/multidict-6.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8c91cdb30809a96d9ecf442ec9bc45e8cfaa0f7f8bdf534e082c2443a196727e", size = 237838, upload-time = "2025-08-11T12:06:42.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/cf/f94af5c36baaa75d44fab9f02e2a6bcfa0cd90acb44d4976a80960759dbc/multidict-6.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1a0ccbfe93ca114c5d65a2471d52d8829e56d467c97b0e341cf5ee45410033b3", size = 246368, upload-time = "2025-08-11T12:06:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/4a/fe/29f23460c3d995f6a4b678cb2e9730e7277231b981f0b234702f0177818a/multidict-6.6.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:55624b3f321d84c403cb7d8e6e982f41ae233d85f85db54ba6286f7295dc8a9c", size = 253339, upload-time = "2025-08-11T12:06:45.597Z" }, - { url = "https://files.pythonhosted.org/packages/29/b6/fd59449204426187b82bf8a75f629310f68c6adc9559dc922d5abe34797b/multidict-6.6.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4a1fb393a2c9d202cb766c76208bd7945bc194eba8ac920ce98c6e458f0b524b", size = 246933, upload-time = "2025-08-11T12:06:46.841Z" }, - { url = "https://files.pythonhosted.org/packages/19/52/d5d6b344f176a5ac3606f7a61fb44dc746e04550e1a13834dff722b8d7d6/multidict-6.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:43868297a5759a845fa3a483fb4392973a95fb1de891605a3728130c52b8f40f", size = 242225, upload-time = "2025-08-11T12:06:48.588Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d3/5b2281ed89ff4d5318d82478a2a2450fcdfc3300da48ff15c1778280ad26/multidict-6.6.4-cp311-cp311-win32.whl", hash = "sha256:ed3b94c5e362a8a84d69642dbeac615452e8af9b8eb825b7bc9f31a53a1051e2", size = 41306, upload-time = "2025-08-11T12:06:49.95Z" }, - { url = "https://files.pythonhosted.org/packages/74/7d/36b045c23a1ab98507aefd44fd8b264ee1dd5e5010543c6fccf82141ccef/multidict-6.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:d8c112f7a90d8ca5d20213aa41eac690bb50a76da153e3afb3886418e61cb22e", size = 46029, upload-time = "2025-08-11T12:06:51.082Z" }, - { url = "https://files.pythonhosted.org/packages/0f/5e/553d67d24432c5cd52b49047f2d248821843743ee6d29a704594f656d182/multidict-6.6.4-cp311-cp311-win_arm64.whl", hash = "sha256:3bb0eae408fa1996d87247ca0d6a57b7fc1dcf83e8a5c47ab82c558c250d4adf", size = 43017, upload-time = "2025-08-11T12:06:52.243Z" }, - { url = "https://files.pythonhosted.org/packages/05/f6/512ffd8fd8b37fb2680e5ac35d788f1d71bbaf37789d21a820bdc441e565/multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8", size = 76516, upload-time = "2025-08-11T12:06:53.393Z" }, - { url = "https://files.pythonhosted.org/packages/99/58/45c3e75deb8855c36bd66cc1658007589662ba584dbf423d01df478dd1c5/multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3", size = 45394, upload-time = "2025-08-11T12:06:54.555Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ca/e8c4472a93a26e4507c0b8e1f0762c0d8a32de1328ef72fd704ef9cc5447/multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b", size = 43591, upload-time = "2025-08-11T12:06:55.672Z" }, - { url = "https://files.pythonhosted.org/packages/05/51/edf414f4df058574a7265034d04c935aa84a89e79ce90fcf4df211f47b16/multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287", size = 237215, upload-time = "2025-08-11T12:06:57.213Z" }, - { url = "https://files.pythonhosted.org/packages/c8/45/8b3d6dbad8cf3252553cc41abea09ad527b33ce47a5e199072620b296902/multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138", size = 258299, upload-time = "2025-08-11T12:06:58.946Z" }, - { url = "https://files.pythonhosted.org/packages/3c/e8/8ca2e9a9f5a435fc6db40438a55730a4bf4956b554e487fa1b9ae920f825/multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6", size = 242357, upload-time = "2025-08-11T12:07:00.301Z" }, - { url = "https://files.pythonhosted.org/packages/0f/84/80c77c99df05a75c28490b2af8f7cba2a12621186e0a8b0865d8e745c104/multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9", size = 268369, upload-time = "2025-08-11T12:07:01.638Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e9/920bfa46c27b05fb3e1ad85121fd49f441492dca2449c5bcfe42e4565d8a/multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c", size = 269341, upload-time = "2025-08-11T12:07:02.943Z" }, - { url = "https://files.pythonhosted.org/packages/af/65/753a2d8b05daf496f4a9c367fe844e90a1b2cac78e2be2c844200d10cc4c/multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402", size = 256100, upload-time = "2025-08-11T12:07:04.564Z" }, - { url = "https://files.pythonhosted.org/packages/09/54/655be13ae324212bf0bc15d665a4e34844f34c206f78801be42f7a0a8aaa/multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7", size = 253584, upload-time = "2025-08-11T12:07:05.914Z" }, - { url = "https://files.pythonhosted.org/packages/5c/74/ab2039ecc05264b5cec73eb018ce417af3ebb384ae9c0e9ed42cb33f8151/multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f", size = 251018, upload-time = "2025-08-11T12:07:08.301Z" }, - { url = "https://files.pythonhosted.org/packages/af/0a/ccbb244ac848e56c6427f2392741c06302bbfba49c0042f1eb3c5b606497/multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d", size = 251477, upload-time = "2025-08-11T12:07:10.248Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b0/0ed49bba775b135937f52fe13922bc64a7eaf0a3ead84a36e8e4e446e096/multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7", size = 263575, upload-time = "2025-08-11T12:07:11.928Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/7fb85a85e14de2e44dfb6a24f03c41e2af8697a6df83daddb0e9b7569f73/multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802", size = 259649, upload-time = "2025-08-11T12:07:13.244Z" }, - { url = "https://files.pythonhosted.org/packages/03/9e/b3a459bcf9b6e74fa461a5222a10ff9b544cb1cd52fd482fb1b75ecda2a2/multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24", size = 251505, upload-time = "2025-08-11T12:07:14.57Z" }, - { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888, upload-time = "2025-08-11T12:07:15.904Z" }, - { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072, upload-time = "2025-08-11T12:07:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222, upload-time = "2025-08-11T12:07:18.328Z" }, - { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, -] - -[[package]] -name = "mypy" -version = "1.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8e/22/ea637422dedf0bf36f3ef238eab4e455e2a0dcc3082b5cc067615347ab8e/mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01", size = 3352570, upload-time = "2025-07-31T07:54:19.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/a9/3d7aa83955617cdf02f94e50aab5c830d205cfa4320cf124ff64acce3a8e/mypy-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3fbe6d5555bf608c47203baa3e72dbc6ec9965b3d7c318aa9a4ca76f465bd972", size = 11003299, upload-time = "2025-07-31T07:54:06.425Z" }, - { url = "https://files.pythonhosted.org/packages/83/e8/72e62ff837dd5caaac2b4a5c07ce769c8e808a00a65e5d8f94ea9c6f20ab/mypy-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80ef5c058b7bce08c83cac668158cb7edea692e458d21098c7d3bce35a5d43e7", size = 10125451, upload-time = "2025-07-31T07:53:52.974Z" }, - { url = "https://files.pythonhosted.org/packages/7d/10/f3f3543f6448db11881776f26a0ed079865926b0c841818ee22de2c6bbab/mypy-1.17.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a580f8a70c69e4a75587bd925d298434057fe2a428faaf927ffe6e4b9a98df", size = 11916211, upload-time = "2025-07-31T07:53:18.879Z" }, - { url = "https://files.pythonhosted.org/packages/06/bf/63e83ed551282d67bb3f7fea2cd5561b08d2bb6eb287c096539feb5ddbc5/mypy-1.17.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd86bb649299f09d987a2eebb4d52d10603224500792e1bee18303bbcc1ce390", size = 12652687, upload-time = "2025-07-31T07:53:30.544Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/68f2eeef11facf597143e85b694a161868b3b006a5fbad50e09ea117ef24/mypy-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a76906f26bd8d51ea9504966a9c25419f2e668f012e0bdf3da4ea1526c534d94", size = 12896322, upload-time = "2025-07-31T07:53:50.74Z" }, - { url = "https://files.pythonhosted.org/packages/a3/87/8e3e9c2c8bd0d7e071a89c71be28ad088aaecbadf0454f46a540bda7bca6/mypy-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:e79311f2d904ccb59787477b7bd5d26f3347789c06fcd7656fa500875290264b", size = 9507962, upload-time = "2025-07-31T07:53:08.431Z" }, - { url = "https://files.pythonhosted.org/packages/46/cf/eadc80c4e0a70db1c08921dcc220357ba8ab2faecb4392e3cebeb10edbfa/mypy-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad37544be07c5d7fba814eb370e006df58fed8ad1ef33ed1649cb1889ba6ff58", size = 10921009, upload-time = "2025-07-31T07:53:23.037Z" }, - { url = "https://files.pythonhosted.org/packages/5d/c1/c869d8c067829ad30d9bdae051046561552516cfb3a14f7f0347b7d973ee/mypy-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:064e2ff508e5464b4bd807a7c1625bc5047c5022b85c70f030680e18f37273a5", size = 10047482, upload-time = "2025-07-31T07:53:26.151Z" }, - { url = "https://files.pythonhosted.org/packages/98/b9/803672bab3fe03cee2e14786ca056efda4bb511ea02dadcedde6176d06d0/mypy-1.17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70401bbabd2fa1aa7c43bb358f54037baf0586f41e83b0ae67dd0534fc64edfd", size = 11832883, upload-time = "2025-07-31T07:53:47.948Z" }, - { url = "https://files.pythonhosted.org/packages/88/fb/fcdac695beca66800918c18697b48833a9a6701de288452b6715a98cfee1/mypy-1.17.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92bdc656b7757c438660f775f872a669b8ff374edc4d18277d86b63edba6b8b", size = 12566215, upload-time = "2025-07-31T07:54:04.031Z" }, - { url = "https://files.pythonhosted.org/packages/7f/37/a932da3d3dace99ee8eb2043b6ab03b6768c36eb29a02f98f46c18c0da0e/mypy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c1fdf4abb29ed1cb091cf432979e162c208a5ac676ce35010373ff29247bcad5", size = 12751956, upload-time = "2025-07-31T07:53:36.263Z" }, - { url = "https://files.pythonhosted.org/packages/8c/cf/6438a429e0f2f5cab8bc83e53dbebfa666476f40ee322e13cac5e64b79e7/mypy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:ff2933428516ab63f961644bc49bc4cbe42bbffb2cd3b71cc7277c07d16b1a8b", size = 9507307, upload-time = "2025-07-31T07:53:59.734Z" }, - { url = "https://files.pythonhosted.org/packages/17/a2/7034d0d61af8098ec47902108553122baa0f438df8a713be860f7407c9e6/mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb", size = 11086295, upload-time = "2025-07-31T07:53:28.124Z" }, - { url = "https://files.pythonhosted.org/packages/14/1f/19e7e44b594d4b12f6ba8064dbe136505cec813549ca3e5191e40b1d3cc2/mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403", size = 10112355, upload-time = "2025-07-31T07:53:21.121Z" }, - { url = "https://files.pythonhosted.org/packages/5b/69/baa33927e29e6b4c55d798a9d44db5d394072eef2bdc18c3e2048c9ed1e9/mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056", size = 11875285, upload-time = "2025-07-31T07:53:55.293Z" }, - { url = "https://files.pythonhosted.org/packages/90/13/f3a89c76b0a41e19490b01e7069713a30949d9a6c147289ee1521bcea245/mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341", size = 12737895, upload-time = "2025-07-31T07:53:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/23/a1/c4ee79ac484241301564072e6476c5a5be2590bc2e7bfd28220033d2ef8f/mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb", size = 12931025, upload-time = "2025-07-31T07:54:17.125Z" }, - { url = "https://files.pythonhosted.org/packages/89/b8/7409477be7919a0608900e6320b155c72caab4fef46427c5cc75f85edadd/mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19", size = 9584664, upload-time = "2025-07-31T07:54:12.842Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f3/8fcd2af0f5b806f6cf463efaffd3c9548a28f84220493ecd38d127b6b66d/mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9", size = 2283411, upload-time = "2025-07-31T07:53:24.664Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - -[[package]] -name = "nodeenv" -version = "1.9.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, -] - -[[package]] -name = "nostr-sdk" -version = "0.42.1" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/09/4e/d413f3620707daad7649a7e83be7b395a0b4130372fb7dc90850e6eb4988/nostr_sdk-0.42.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:57ac8b7a6f56bb619a3ef58fbfe1bd93e180d5cd73681b45eeaf5ae2dd45d345", size = 3925439, upload-time = "2025-05-26T09:08:53.522Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2b/ae1dadd3390846d067bd6602f484e690232f868dc30f11d138262ec9ca97/nostr_sdk-0.42.1-cp39-abi3-macosx_11_0_x86_64.whl", hash = "sha256:8897a4c3c34aa3cf4d500e570a1697ba253f89223e8548d2b75da2006fc730eb", size = 3988204, upload-time = "2025-05-26T09:09:06.853Z" }, - { url = "https://files.pythonhosted.org/packages/e9/8d/f0f8def4999384df6739dd5dce8166454a6bacf73d374e5984b3f1d25e79/nostr_sdk-0.42.1-cp39-abi3-manylinux_2_17_aarch64.whl", hash = "sha256:166f1c7b1901813ad9a6296641384cbcc7e35c18ccdf3229d88fcd1e0b51d6ee", size = 3673138, upload-time = "2025-05-26T09:08:42.03Z" }, - { url = "https://files.pythonhosted.org/packages/fb/50/2fea941dcfcd311250f181913dd23031ad8422820acefe86914935c09a6e/nostr_sdk-0.42.1-cp39-abi3-manylinux_2_17_armv7l.whl", hash = "sha256:f2b86bc50805be59113c974665bdf4dcaaf76a1f591a9dba41f1d50acb674b3c", size = 3420691, upload-time = "2025-05-26T09:08:49.608Z" }, - { url = "https://files.pythonhosted.org/packages/cd/cf/cdf16f83a5ed1bfe88512fb4a899ec395296c6b63bb19d329d3cb51f20f6/nostr_sdk-0.42.1-cp39-abi3-manylinux_2_17_i686.whl", hash = "sha256:73b0c16494221a0faf8ec369a2609faba3f17ffd0f03620baa0ec83c58c49b71", size = 3706012, upload-time = "2025-05-26T09:08:57.181Z" }, - { url = "https://files.pythonhosted.org/packages/4a/ee/9b1e90670fe32bb4d520c73dfe25035dc989518f43ae9facf7d4e286179e/nostr_sdk-0.42.1-cp39-abi3-manylinux_2_17_x86_64.whl", hash = "sha256:f68663073630c8edee55d9bec689f019ea616ef6482857e7f72ba1dc1b4fa9cc", size = 3817054, upload-time = "2025-05-26T09:09:11.778Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f3/8b62b3bc24c63a5c165669dfb444c877275959fe7983a80a5e0c60e8ff42/nostr_sdk-0.42.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b6659308d7629e23be252455c46d81549fe6bf945915ec4a0cba2c4b61815c07", size = 3670777, upload-time = "2025-05-26T09:09:02.686Z" }, - { url = "https://files.pythonhosted.org/packages/62/14/78851a956ab8dae47c28812a89c93e883aeadf2655596522a72030943541/nostr_sdk-0.42.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1fa8a8688cf17e838c31caf5c8a357b7c7c2ba3b6ee85e39c05c93e80d999b99", size = 3419180, upload-time = "2025-05-26T09:09:16.714Z" }, - { url = "https://files.pythonhosted.org/packages/b4/06/41bad843700ce7d47c2ef2a17ccd823db9e10504c4d368168f24c91f2d81/nostr_sdk-0.42.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:0a89ae1c7b2c36025024511240aa10540aade2551e8b97d40b4a0b88feaabe8c", size = 3571964, upload-time = "2025-05-26T09:08:45.516Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ea/d62f311a90faae44d4e0c0d6646c5a8dca10124bd5706fa3fa51156d892c/nostr_sdk-0.42.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3dda5d65591158a0c9794aa79a2f2ea7bbe3cfcba236645fbff8291d94946f1", size = 3822060, upload-time = "2025-05-26T09:08:39.336Z" }, - { url = "https://files.pythonhosted.org/packages/f3/39/935f70ee05a25de220d236774a1258d13b470595954bd5fd1ffcf4f5df2a/nostr_sdk-0.42.1-cp39-abi3-win32.whl", hash = "sha256:d20988c0d6dd16c183607dfdbd243e4f5cc9d8a4faa0c498df546fa8383da902", size = 3254061, upload-time = "2025-05-26T09:09:14.515Z" }, - { url = "https://files.pythonhosted.org/packages/7f/9e/d509f09b98c4d91f339bd68d3a9d6c313161e54554e392d001466206ca67/nostr_sdk-0.42.1-cp39-abi3-win_amd64.whl", hash = "sha256:35978a8e526e66d05a346546391c28d643dfca31117a95c51c940c994c422d7d", size = 3500340, upload-time = "2025-05-26T09:09:00.279Z" }, - { url = "https://files.pythonhosted.org/packages/bc/0f/f704d328499458434b45c54ef88bdfee803366e0c9ef32f3377e2f396921/nostr_sdk-0.42.1-cp39-abi3-win_arm64.whl", hash = "sha256:802c74f903b120b74bf501bf68c81d5ee1dc9f7f57801a53a507044a14b9a840", size = 3303179, upload-time = "2025-05-26T09:09:09.122Z" }, -] - -[[package]] -name = "oauthlib" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, -] - -[[package]] -name = "packaging" -version = "25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, -] - -[[package]] -name = "passlib" -version = "1.7.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, -] - -[[package]] -name = "pathspec" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.3.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "ply" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3", size = 159130, upload-time = "2018-02-15T19:01:31.097Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567, upload-time = "2018-02-15T19:01:27.172Z" }, -] - -[[package]] -name = "pre-commit" -version = "4.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cfgv" }, - { name = "identify" }, - { name = "nodeenv" }, - { name = "pyyaml" }, - { name = "virtualenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, -] - -[[package]] -name = "propcache" -version = "0.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/14/510deed325e262afeb8b360043c5d7c960da7d3ecd6d6f9496c9c56dc7f4/propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770", size = 73178, upload-time = "2025-06-09T22:53:40.126Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4e/ad52a7925ff01c1325653a730c7ec3175a23f948f08626a534133427dcff/propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3", size = 43133, upload-time = "2025-06-09T22:53:41.965Z" }, - { url = "https://files.pythonhosted.org/packages/63/7c/e9399ba5da7780871db4eac178e9c2e204c23dd3e7d32df202092a1ed400/propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3", size = 43039, upload-time = "2025-06-09T22:53:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/22/e1/58da211eb8fdc6fc854002387d38f415a6ca5f5c67c1315b204a5d3e9d7a/propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e", size = 201903, upload-time = "2025-06-09T22:53:44.872Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0a/550ea0f52aac455cb90111c8bab995208443e46d925e51e2f6ebdf869525/propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220", size = 213362, upload-time = "2025-06-09T22:53:46.707Z" }, - { url = "https://files.pythonhosted.org/packages/5a/af/9893b7d878deda9bb69fcf54600b247fba7317761b7db11fede6e0f28bd0/propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb", size = 210525, upload-time = "2025-06-09T22:53:48.547Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bb/38fd08b278ca85cde36d848091ad2b45954bc5f15cce494bb300b9285831/propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614", size = 198283, upload-time = "2025-06-09T22:53:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/78/8c/9fe55bd01d362bafb413dfe508c48753111a1e269737fa143ba85693592c/propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50", size = 191872, upload-time = "2025-06-09T22:53:51.438Z" }, - { url = "https://files.pythonhosted.org/packages/54/14/4701c33852937a22584e08abb531d654c8bcf7948a8f87ad0a4822394147/propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339", size = 199452, upload-time = "2025-06-09T22:53:53.229Z" }, - { url = "https://files.pythonhosted.org/packages/16/44/447f2253d859602095356007657ee535e0093215ea0b3d1d6a41d16e5201/propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0", size = 191567, upload-time = "2025-06-09T22:53:54.541Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b3/e4756258749bb2d3b46defcff606a2f47410bab82be5824a67e84015b267/propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2", size = 193015, upload-time = "2025-06-09T22:53:56.44Z" }, - { url = "https://files.pythonhosted.org/packages/1e/df/e6d3c7574233164b6330b9fd697beeac402afd367280e6dc377bb99b43d9/propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7", size = 204660, upload-time = "2025-06-09T22:53:57.839Z" }, - { url = "https://files.pythonhosted.org/packages/b2/53/e4d31dd5170b4a0e2e6b730f2385a96410633b4833dc25fe5dffd1f73294/propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b", size = 206105, upload-time = "2025-06-09T22:53:59.638Z" }, - { url = "https://files.pythonhosted.org/packages/7f/fe/74d54cf9fbe2a20ff786e5f7afcfde446588f0cf15fb2daacfbc267b866c/propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c", size = 196980, upload-time = "2025-06-09T22:54:01.071Z" }, - { url = "https://files.pythonhosted.org/packages/22/ec/c469c9d59dada8a7679625e0440b544fe72e99311a4679c279562051f6fc/propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70", size = 37679, upload-time = "2025-06-09T22:54:03.003Z" }, - { url = "https://files.pythonhosted.org/packages/38/35/07a471371ac89d418f8d0b699c75ea6dca2041fbda360823de21f6a9ce0a/propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9", size = 41459, upload-time = "2025-06-09T22:54:04.134Z" }, - { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207, upload-time = "2025-06-09T22:54:05.399Z" }, - { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648, upload-time = "2025-06-09T22:54:08.023Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496, upload-time = "2025-06-09T22:54:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288, upload-time = "2025-06-09T22:54:10.466Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456, upload-time = "2025-06-09T22:54:11.828Z" }, - { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429, upload-time = "2025-06-09T22:54:13.823Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472, upload-time = "2025-06-09T22:54:15.232Z" }, - { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480, upload-time = "2025-06-09T22:54:17.104Z" }, - { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530, upload-time = "2025-06-09T22:54:18.512Z" }, - { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230, upload-time = "2025-06-09T22:54:19.947Z" }, - { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754, upload-time = "2025-06-09T22:54:21.716Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430, upload-time = "2025-06-09T22:54:23.17Z" }, - { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884, upload-time = "2025-06-09T22:54:25.539Z" }, - { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480, upload-time = "2025-06-09T22:54:26.892Z" }, - { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757, upload-time = "2025-06-09T22:54:28.241Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500, upload-time = "2025-06-09T22:54:29.4Z" }, - { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, - { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, - { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, - { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, - { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, - { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, - { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, - { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, - { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, -] - -[[package]] -name = "protobuf" -version = "5.29.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d2/4f/1639b7b1633d8fd55f216ba01e21bf2c43384ab25ef3ddb35d85a52033e8/protobuf-5.29.1.tar.gz", hash = "sha256:683be02ca21a6ffe80db6dd02c0b5b2892322c59ca57fd6c872d652cb80549cb", size = 424965, upload-time = "2024-12-04T19:48:10.986Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/50/c7/28669b04691a376cf7d0617d612f126aa0fff763d57df0142f9bf474c5b8/protobuf-5.29.1-cp310-abi3-win32.whl", hash = "sha256:22c1f539024241ee545cbcb00ee160ad1877975690b16656ff87dde107b5f110", size = 422706, upload-time = "2024-12-04T19:47:54.119Z" }, - { url = "https://files.pythonhosted.org/packages/e3/33/dc7a7712f457456b7e0b16420ab8ba1cc8686751d3f28392eb43d0029ab9/protobuf-5.29.1-cp310-abi3-win_amd64.whl", hash = "sha256:1fc55267f086dd4050d18ef839d7bd69300d0d08c2a53ca7df3920cc271a3c34", size = 434505, upload-time = "2024-12-04T19:47:56.955Z" }, - { url = "https://files.pythonhosted.org/packages/e5/39/44239fb1c6ec557e1731d996a5de89a9eb1ada7a92491fcf9c5d714052ed/protobuf-5.29.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:d473655e29c0c4bbf8b69e9a8fb54645bc289dead6d753b952e7aa660254ae18", size = 417822, upload-time = "2024-12-04T19:47:58.194Z" }, - { url = "https://files.pythonhosted.org/packages/fb/4a/ec56f101d38d4bef2959a9750209809242d86cf8b897db00f2f98bfa360e/protobuf-5.29.1-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5ba1d0e4c8a40ae0496d0e2ecfdbb82e1776928a205106d14ad6985a09ec155", size = 319572, upload-time = "2024-12-04T19:48:00.053Z" }, - { url = "https://files.pythonhosted.org/packages/04/52/c97c58a33b3d6c89a8138788576d372a90a6556f354799971c6b4d16d871/protobuf-5.29.1-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ee1461b3af56145aca2800e6a3e2f928108c749ba8feccc6f5dd0062c410c0d", size = 319671, upload-time = "2024-12-04T19:48:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/3b/24/c8c49df8f6587719e1d400109b16c10c6902d0c9adddc8fff82840146f99/protobuf-5.29.1-py3-none-any.whl", hash = "sha256:32600ddb9c2a53dedc25b8581ea0f1fd8ea04956373c0c07577ce58d312522e0", size = 172547, upload-time = "2024-12-04T19:48:09.17Z" }, -] - -[[package]] -name = "py-vapid" -version = "1.9.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ff/57/5c1c61f27ce01f939443cf3f6c279a295f7ec0327b18a1cbbcfefe0b5456/py_vapid-1.9.2.tar.gz", hash = "sha256:3c8973b6cf8384ad0c9ae64d6270ccc480e0b92c702d8f5ea2cc03e6b51247f9", size = 20300, upload-time = "2024-11-19T21:55:41.859Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/fb/b877a221b09dabcebeb073d5e7f19244f3fa1d5aec87092c359a6049a006/py_vapid-1.9.2-py3-none-any.whl", hash = "sha256:4ccf8a00fc54f1f99f66fb543c96f2c82622508ad814b6e9225f2c26948934d7", size = 21492, upload-time = "2024-11-19T21:55:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, ] [[package]] @@ -1442,66 +491,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, ] -[[package]] -name = "pycryptodomex" -version = "3.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, - { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, - { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, - { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, - { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, - { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, - { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, - { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, - { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, - { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b8/3e76d948c3c4ac71335bbe75dac53e154b40b0f8f1f022dfa295257a0c96/pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5", size = 1627695, upload-time = "2025-05-17T17:23:17.38Z" }, - { url = "https://files.pythonhosted.org/packages/6a/cf/80f4297a4820dfdfd1c88cf6c4666a200f204b3488103d027b5edd9176ec/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798", size = 1675772, upload-time = "2025-05-17T17:23:19.202Z" }, - { url = "https://files.pythonhosted.org/packages/d1/42/1e969ee0ad19fe3134b0e1b856c39bd0b70d47a4d0e81c2a8b05727394c9/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f", size = 1668083, upload-time = "2025-05-17T17:23:21.867Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c3/1de4f7631fea8a992a44ba632aa40e0008764c0fb9bf2854b0acf78c2cf2/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea", size = 1706056, upload-time = "2025-05-17T17:23:24.031Z" }, - { url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478, upload-time = "2025-05-17T17:23:26.066Z" }, -] - [[package]] name = "pydantic" -version = "1.10.18" +version = "1.10.22" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/e6/89d6ba0c0a981fd7e3129d105502c4cf73fad1611b294c87b103f75b5837/pydantic-1.10.18.tar.gz", hash = "sha256:baebdff1907d1d96a139c25136a9bb7d17e118f133a76a2ef3b845e831e3403a", size = 354731, upload-time = "2024-08-22T23:18:50.891Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/1a/cad745befc17527e48ba071c8f98c4a8840fae04614f77f1fb1ac2bb7998/pydantic-1.10.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e405ffcc1254d76bb0e760db101ee8916b620893e6edfbfee563b3c6f7a67c02", size = 2608980, upload-time = "2024-08-22T23:17:35.432Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e3/2becd3da22de005873337d7aae4a608095624a9cda87a7d36d912d7dca0a/pydantic-1.10.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e306e280ebebc65040034bff1a0a81fd86b2f4f05daac0131f29541cafd80b80", size = 2297349, upload-time = "2024-08-22T23:17:38.685Z" }, - { url = "https://files.pythonhosted.org/packages/0a/40/e05503faf87c6ee6cd0bcd954c510a0d4471a534ff5d1ebeed4957d56ecb/pydantic-1.10.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11d9d9b87b50338b1b7de4ebf34fd29fdb0d219dc07ade29effc74d3d2609c62", size = 3122605, upload-time = "2024-08-22T23:17:40.476Z" }, - { url = "https://files.pythonhosted.org/packages/47/fb/da7a7ac29690678f8b868c0b5c1ae2e335fdebd2dd016a4d65072f3c7942/pydantic-1.10.18-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b661ce52c7b5e5f600c0c3c5839e71918346af2ef20062705ae76b5c16914cab", size = 3150697, upload-time = "2024-08-22T23:17:42.479Z" }, - { url = "https://files.pythonhosted.org/packages/05/18/b000a4f5c123747dc266e799058c18bb9dc4f0f61b18cd7cc9ecc8e37735/pydantic-1.10.18-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c20f682defc9ef81cd7eaa485879ab29a86a0ba58acf669a78ed868e72bb89e0", size = 3181979, upload-time = "2024-08-22T23:17:44.422Z" }, - { url = "https://files.pythonhosted.org/packages/92/d2/29717af655b5eabaca1a1c17667ce7d0d88ba9c5f6dc138bf6c4f0600142/pydantic-1.10.18-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c5ae6b7c8483b1e0bf59e5f1843e4fd8fd405e11df7de217ee65b98eb5462861", size = 3142754, upload-time = "2024-08-22T23:17:46.288Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a3/12da23a41ffb15acbfee1abe783178c4caea91dbe93d741a1ea18f8e3bd9/pydantic-1.10.18-cp310-cp310-win_amd64.whl", hash = "sha256:74fe19dda960b193b0eb82c1f4d2c8e5e26918d9cda858cbf3f41dd28549cb70", size = 2139059, upload-time = "2024-08-22T23:17:48.107Z" }, - { url = "https://files.pythonhosted.org/packages/42/c1/d5f1a844e2bc08ccb0330d2d656595e177aba494d69a3fd42e3a35f11526/pydantic-1.10.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72fa46abace0a7743cc697dbb830a41ee84c9db8456e8d77a46d79b537efd7ec", size = 2582713, upload-time = "2024-08-22T23:17:49.882Z" }, - { url = "https://files.pythonhosted.org/packages/1b/f4/0cf037f5c03c4d8aa18439d0146e11c2cfcca936388993a6868c803f3db1/pydantic-1.10.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef0fe7ad7cbdb5f372463d42e6ed4ca9c443a52ce544472d8842a0576d830da5", size = 2268850, upload-time = "2024-08-22T23:17:51.669Z" }, - { url = "https://files.pythonhosted.org/packages/d1/34/1a9c2745e28ca5891adbff2730c1f81f3e191aeb1c2108c8c7757dfa1989/pydantic-1.10.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a00e63104346145389b8e8f500bc6a241e729feaf0559b88b8aa513dd2065481", size = 3088192, upload-time = "2024-08-22T23:17:53.382Z" }, - { url = "https://files.pythonhosted.org/packages/32/d7/62134d1662af1af476b1103aea8b3ab928ff737624f8e23beeef1fbffde1/pydantic-1.10.18-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae6fa2008e1443c46b7b3a5eb03800121868d5ab6bc7cda20b5df3e133cde8b3", size = 3119361, upload-time = "2024-08-22T23:17:55.219Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e2/0e3b2250f470faa4ff150ff5e6729e1cec62e35abfc8f5150f4e794e4585/pydantic-1.10.18-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9f463abafdc92635da4b38807f5b9972276be7c8c5121989768549fceb8d2588", size = 3163945, upload-time = "2024-08-22T23:17:57.016Z" }, - { url = "https://files.pythonhosted.org/packages/a2/de/ed086c435121c4bf915720528637e4fe7edc9b00057337512b122f1bc2db/pydantic-1.10.18-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3445426da503c7e40baccefb2b2989a0c5ce6b163679dd75f55493b460f05a8f", size = 3114871, upload-time = "2024-08-22T23:17:58.466Z" }, - { url = "https://files.pythonhosted.org/packages/8d/e0/c50b1492846e946eed84b24d673ca7648f68a219c61c1a100f5b892089b4/pydantic-1.10.18-cp311-cp311-win_amd64.whl", hash = "sha256:467a14ee2183bc9c902579bb2f04c3d3dac00eff52e252850509a562255b2a33", size = 2118200, upload-time = "2024-08-22T23:18:00.353Z" }, - { url = "https://files.pythonhosted.org/packages/be/27/baa1ec62b24590bcf86e2c682cc47a7f57b5fd301cd2354c141a27adf2c4/pydantic-1.10.18-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:efbc8a7f9cb5fe26122acba1852d8dcd1e125e723727c59dcd244da7bdaa54f2", size = 2409940, upload-time = "2024-08-22T23:18:02.105Z" }, - { url = "https://files.pythonhosted.org/packages/b1/db/5189378620fef502cd1e7002dfe0d9f129e09eb1e4a51476ee5f4fbaeeab/pydantic-1.10.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24a4a159d0f7a8e26bf6463b0d3d60871d6a52eac5bb6a07a7df85c806f4c048", size = 2165312, upload-time = "2024-08-22T23:18:03.981Z" }, - { url = "https://files.pythonhosted.org/packages/9b/8b/85ab3deec6cc18c1cabd5d8f329f26267d5bd2c4b6773a75c47f4e5cfd64/pydantic-1.10.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b74be007703547dc52e3c37344d130a7bfacca7df112a9e5ceeb840a9ce195c7", size = 2798664, upload-time = "2024-08-22T23:18:05.768Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f9/1e1badfe27769d1f18d23c268776f67cff20e5411c2931da6bed0805fcc7/pydantic-1.10.18-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcb20d4cb355195c75000a49bb4a31d75e4295200df620f454bbc6bdf60ca890", size = 2829080, upload-time = "2024-08-22T23:18:07.368Z" }, - { url = "https://files.pythonhosted.org/packages/35/4d/5a4e28a95e9fac3c3964836b10074f6b7cd789d6491876333c3ffbf8c14d/pydantic-1.10.18-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:46f379b8cb8a3585e3f61bf9ae7d606c70d133943f339d38b76e041ec234953f", size = 2862046, upload-time = "2024-08-22T23:18:09.21Z" }, - { url = "https://files.pythonhosted.org/packages/da/66/ab281b4a0754fcfce903d0fd6507341e7278b3a58fb6a0d9ad2240f09348/pydantic-1.10.18-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cbfbca662ed3729204090c4d09ee4beeecc1a7ecba5a159a94b5a4eb24e3759a", size = 2822412, upload-time = "2024-08-22T23:18:11.252Z" }, - { url = "https://files.pythonhosted.org/packages/0b/6f/273755a76305ac86f7244623a60203cc0a6ab030d0daced3d4966e2023a2/pydantic-1.10.18-cp312-cp312-win_amd64.whl", hash = "sha256:c6d0a9f9eccaf7f438671a64acf654ef0d045466e63f9f68a579e2383b63f357", size = 1944247, upload-time = "2024-08-22T23:18:13.192Z" }, - { url = "https://files.pythonhosted.org/packages/67/a4/0048b8c96b97147de57f102034dd20a35178ff70cb28707e1fb17570c1bc/pydantic-1.10.18-py3-none-any.whl", hash = "sha256:06a189b81ffc52746ec9c8c007f16e5167c8b0a696e1a726369327e3db7b2a82", size = 165698, upload-time = "2024-08-22T23:18:49.599Z" }, -] - -[package.optional-dependencies] -email = [ - { name = "email-validator" }, +sdist = { url = "https://files.pythonhosted.org/packages/9a/57/5996c63f0deec09e9e901a2b838247c97c6844999562eac4e435bcb83938/pydantic-1.10.22.tar.gz", hash = "sha256:ee1006cebd43a8e7158fb7190bb8f4e2da9649719bff65d0c287282ec38dec6d", size = 356771, upload-time = "2025-04-24T13:38:43.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/92/91eb5c75a1460292e1f2f3e577122574ebb942fbac19ad2369ff00b9eb24/pydantic-1.10.22-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:57889565ccc1e5b7b73343329bbe6198ebc472e3ee874af2fa1865cfe7048228", size = 2852481, upload-time = "2025-04-24T13:36:55.045Z" }, + { url = "https://files.pythonhosted.org/packages/08/f3/dd54b49fc5caaed06f5a0d0a5ec35a81cf722cd6b42455f408dad1ef3f7d/pydantic-1.10.22-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:90729e22426de79bc6a3526b4c45ec4400caf0d4f10d7181ba7f12c01bb3897d", size = 2585586, upload-time = "2025-04-24T13:36:58.453Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9b/48d10180cc614ffb66da486e99bc1f8b639fb44edf322864f2fb161e2351/pydantic-1.10.22-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8684d347f351554ec94fdcb507983d3116dc4577fb8799fed63c65869a2d10", size = 3336974, upload-time = "2025-04-24T13:37:00.652Z" }, + { url = "https://files.pythonhosted.org/packages/ff/80/b55ad0029ae8e7b8b5c81ad7c4e800774a52107d26f70c6696857dc733d5/pydantic-1.10.22-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8dad498ceff2d9ef1d2e2bc6608f5b59b8e1ba2031759b22dfb8c16608e1802", size = 3362338, upload-time = "2025-04-24T13:37:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/65/e0/8a5cd2cd29a5632581ba466f5792194b2a568aa052ce9da9ba98b634debf/pydantic-1.10.22-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fac529cc654d4575cf8de191cce354b12ba705f528a0a5c654de6d01f76cd818", size = 3519505, upload-time = "2025-04-24T13:37:04.322Z" }, + { url = "https://files.pythonhosted.org/packages/38/c5/c776d03ec374f22860802b2cee057b41e866be3c80826b53d4c001692db3/pydantic-1.10.22-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4148232aded8dd1dd13cf910a01b32a763c34bd79a0ab4d1ee66164fcb0b7b9d", size = 3485878, upload-time = "2025-04-24T13:37:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a2/1efd064513a2c1bcb5c2b0e022cdf77d132ef7f7f20d91bb439d759f6a88/pydantic-1.10.22-cp310-cp310-win_amd64.whl", hash = "sha256:ece68105d9e436db45d8650dc375c760cc85a6793ae019c08769052902dca7db", size = 2299673, upload-time = "2025-04-24T13:37:07.969Z" }, + { url = "https://files.pythonhosted.org/packages/42/03/e435ed85a9abda29e3fbdb49c572fe4131a68c6daf3855a01eebda9e1b27/pydantic-1.10.22-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8e530a8da353f791ad89e701c35787418605d35085f4bdda51b416946070e938", size = 2845682, upload-time = "2025-04-24T13:37:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/72/ea/4a625035672f6c06d3f1c7e33aa0af6bf1929991e27017e98b9c2064ae0b/pydantic-1.10.22-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:654322b85642e9439d7de4c83cb4084ddd513df7ff8706005dada43b34544946", size = 2553286, upload-time = "2025-04-24T13:37:11.946Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f0/424ad837746e69e9f061ba9be68c2a97aef7376d1911692904d8efbcd322/pydantic-1.10.22-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8bece75bd1b9fc1c32b57a32831517943b1159ba18b4ba32c0d431d76a120ae", size = 3141232, upload-time = "2025-04-24T13:37:14.394Z" }, + { url = "https://files.pythonhosted.org/packages/14/67/4979c19e8cfd092085a292485e0b42d74e4eeefbb8cd726aa8ba38d06294/pydantic-1.10.22-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eccb58767f13c6963dcf96d02cb8723ebb98b16692030803ac075d2439c07b0f", size = 3214272, upload-time = "2025-04-24T13:37:16.201Z" }, + { url = "https://files.pythonhosted.org/packages/1a/04/32339ce43e97519d19e7759902515c750edbf4832a13063a4ab157f83f42/pydantic-1.10.22-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7778e6200ff8ed5f7052c1516617423d22517ad36cc7a3aedd51428168e3e5e8", size = 3321646, upload-time = "2025-04-24T13:37:19.086Z" }, + { url = "https://files.pythonhosted.org/packages/92/35/dffc1b29cb7198aadab68d75447191e59bdbc1f1d2d51826c9a4460d372f/pydantic-1.10.22-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bffe02767d27c39af9ca7dc7cd479c00dda6346bb62ffc89e306f665108317a2", size = 3244258, upload-time = "2025-04-24T13:37:20.929Z" }, + { url = "https://files.pythonhosted.org/packages/11/c5/c4ce6ebe7f528a879441eabd2c6dd9e2e4c54f320a8c9344ba93b3aa8701/pydantic-1.10.22-cp311-cp311-win_amd64.whl", hash = "sha256:23bc19c55427091b8e589bc08f635ab90005f2dc99518f1233386f46462c550a", size = 2309702, upload-time = "2025-04-24T13:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a3/ec66239ed7c9e90edfb85b23b6b18eb290ed7aa05f54837cdcb6a14faa98/pydantic-1.10.22-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:92d0f97828a075a71d9efc65cf75db5f149b4d79a38c89648a63d2932894d8c9", size = 2794865, upload-time = "2025-04-24T13:37:25.087Z" }, + { url = "https://files.pythonhosted.org/packages/49/6a/99cf3fee612d93210c85f45a161e98c1c5b45b6dcadb21c9f1f838fa9e28/pydantic-1.10.22-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6af5a2811b6b95b58b829aeac5996d465a5f0c7ed84bd871d603cf8646edf6ff", size = 2534212, upload-time = "2025-04-24T13:37:26.848Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e6/0f8882775cd9a60b221103ee7d6a89e10eb5a892d877c398df0da7140704/pydantic-1.10.22-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cf06d8d40993e79af0ab2102ef5da77b9ddba51248e4cb27f9f3f591fbb096e", size = 2994027, upload-time = "2025-04-24T13:37:28.683Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a3/f20fdecbaa2a2721a6a8ee9e4f344d1f72bd7d56e679371c3f2be15eb8c8/pydantic-1.10.22-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:184b7865b171a6057ad97f4a17fbac81cec29bd103e996e7add3d16b0d95f609", size = 3036716, upload-time = "2025-04-24T13:37:30.547Z" }, + { url = "https://files.pythonhosted.org/packages/1f/83/dab34436d830c38706685acc77219fc2a209fea2a2301a1b05a2865b28bf/pydantic-1.10.22-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:923ad861677ab09d89be35d36111156063a7ebb44322cdb7b49266e1adaba4bb", size = 3171801, upload-time = "2025-04-24T13:37:32.474Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6e/b64deccb8a7304d584088972437ea3091e9d99d27a8e7bf2bd08e29ae84e/pydantic-1.10.22-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:82d9a3da1686443fb854c8d2ab9a473251f8f4cdd11b125522efb4d7c646e7bc", size = 3123560, upload-time = "2025-04-24T13:37:34.855Z" }, + { url = "https://files.pythonhosted.org/packages/08/9a/90d1ab704329a7ae8666354be84b5327d655764003974364767c9d307d3a/pydantic-1.10.22-cp312-cp312-win_amd64.whl", hash = "sha256:1612604929af4c602694a7f3338b18039d402eb5ddfbf0db44f1ebfaf07f93e7", size = 2191378, upload-time = "2025-04-24T13:37:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e0/1ed151a56869be1588ad2d8cda9f8c1d95b16f74f09a7cea879ca9b63a8b/pydantic-1.10.22-py3-none-any.whl", hash = "sha256:343037d608bcbd34df937ac259708bfc83664dadf88afe8516c4f282d7d471a9", size = 166503, upload-time = "2025-04-24T13:38:41.374Z" }, ] [[package]] @@ -1513,86 +533,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] -[[package]] -name = "pyjwt" -version = "2.10.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, -] - -[[package]] -name = "pyln-bolt7" -version = "1.0.246" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/3c/6d1d6643c6a501e998c17c25d40c3b68ab75be1d16d9055e6a9ffba30fe4/pyln-bolt7-1.0.246.tar.gz", hash = "sha256:2b53744fa21c1b12d2c9c9df153651b122e38fa65d4a5c3f2957317ee148e089", size = 17905, upload-time = "2022-08-31T17:28:20.093Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/f1/30b626e7cec279a2f84084898594c8e84537d6d9af9afbe9858f9a6d8e13/pyln_bolt7-1.0.246-py3-none-any.whl", hash = "sha256:54d48ec27fdc8751762cb068b0a9f2757a58fb57933c6d8f8255d02c27eb63c5", size = 18811, upload-time = "2022-08-31T17:28:22.137Z" }, -] - -[[package]] -name = "pyln-client" -version = "25.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyln-bolt7" }, - { name = "pyln-proto" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/f5/70dae7e6985ba44f53e1c634e204ecc4e9ccde8de6cd90e009672203023f/pyln_client-25.5.tar.gz", hash = "sha256:18656e667c7218f8b40c70f893b936aef4518e87e3ce99081bd52f89e6e5af85", size = 36015, upload-time = "2025-06-16T18:13:48.973Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/45/c504ed8ea59577d8093d377e2f657482f08e87b93aaa039e64421297ff55/pyln_client-25.5-py3-none-any.whl", hash = "sha256:269f9ab346ff679b532af2d24c2a524201884efc5b4781dd40685c8ccaec9507", size = 37362, upload-time = "2025-06-16T18:13:47.394Z" }, -] - -[[package]] -name = "pyln-proto" -version = "25.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "base58" }, - { name = "bitstring" }, - { name = "coincurve" }, - { name = "cryptography" }, - { name = "pysocks" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/44/6f/48b7879c11b698b0004e1b9cf820ce26454fbfa893181a009df7d7184189/pyln_proto-25.5.tar.gz", hash = "sha256:c5e38b726123af723f8c6a4f38ab310cbec46579f52c8e6f666e6beef320b96c", size = 27314, upload-time = "2025-06-16T18:13:46.302Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/31/4f5ef88ec5abe6a2e2b84f1bb5e1d0818d07f8afd372570916493849bf6c/pyln_proto-25.5-py3-none-any.whl", hash = "sha256:31abcf193744d6253b7f06b7712983c6a4d4c28815d46e1f3803eac17bfe1cf9", size = 31902, upload-time = "2025-06-16T18:13:45.07Z" }, -] - -[[package]] -name = "pynostr" -version = "0.6.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "coincurve" }, - { name = "cryptography" }, - { name = "requests" }, - { name = "rich" }, - { name = "tlv8" }, - { name = "tornado" }, - { name = "typer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/08/b6/818c072bd98c6472c305fa0ce9822592a3c8601ebe550b02465404fa629b/pynostr-0.6.2.tar.gz", hash = "sha256:2974ea05b3ff41a1a4060e3b1813eb0ce0e60c0b81fbe668afaa65164c7f82f4", size = 52310, upload-time = "2023-03-16T20:54:02.826Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/4c/d76378abc44c15375708cb3eb8ee9f199879b905b4c2ceda9ea108dfae79/pynostr-0.6.2-py3-none-any.whl", hash = "sha256:d43fb236c73174093275ee0080b2f8ed17e974b2b516f0d73da4c9a3e908ddc5", size = 36301, upload-time = "2023-03-16T20:54:01.055Z" }, -] - -[[package]] -name = "pyqrcode" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/61/f07226075c347897937d4086ef8e55f0a62ae535e28069884ac68d979316/PyQRCode-1.2.1.tar.gz", hash = "sha256:fdbf7634733e56b72e27f9bce46e4550b75a3a2c420414035cae9d9d26b234d5", size = 36989, upload-time = "2016-06-20T03:28:03.411Z" } - -[[package]] -name = "pysocks" -version = "1.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, -] - [[package]] name = "pytest" version = "8.4.1" @@ -1624,65 +564,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, ] -[[package]] -name = "python-crontab" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e2/f0/25775565c133d4e29eeb607bf9ddba0075f3af36041a1844dd207881047f/python_crontab-3.2.0.tar.gz", hash = "sha256:40067d1dd39ade3460b2ad8557c7651514cd3851deffff61c5c60e1227c5c36b", size = 57001, upload-time = "2024-07-01T22:29:10.903Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/91/832fb3b3a1f62bd2ab4924f6be0c7736c9bc4f84d3b153b74efcf6d4e4a1/python_crontab-3.2.0-py3-none-any.whl", hash = "sha256:82cb9b6a312d41ff66fd3caf3eed7115c28c195bfb50711bc2b4b9592feb9fe5", size = 27351, upload-time = "2024-07-01T22:29:08.549Z" }, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, -] - -[[package]] -name = "python-multipart" -version = "0.0.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, -] - -[[package]] -name = "pywebpush" -version = "2.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "cryptography" }, - { name = "http-ece" }, - { name = "py-vapid" }, - { name = "requests" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/41/ca/6e669bf676916d66c8c7adedc291e9a9758650f9d85ec040fda13e3c82f4/pywebpush-2.0.3.tar.gz", hash = "sha256:584878e3c243e873a22db8895505d95715bc796ef74cc1b8fe99f596174161e3", size = 25874, upload-time = "2024-11-19T21:30:20.444Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/8a/ecaa2a589338a038b89148b01a5db2dac53e45918342da69baca1fb058fc/pywebpush-2.0.3-py3-none-any.whl", hash = "sha256:04666441715bc547918d7668b2ac7ad5c4b5de7d0a6cf528daf61e0c4bc5431c", size = 21364, upload-time = "2024-11-19T21:30:19.312Z" }, -] - [[package]] name = "pyyaml" version = "6.0.2" @@ -1718,34 +599,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, ] -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, -] - -[[package]] -name = "rich" -version = "14.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, -] - [[package]] name = "ruff" version = "0.12.10" @@ -1773,36 +626,52 @@ wheels = [ ] [[package]] -name = "secp256k1" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } +name = "silnt" +version = "0.1.0" +source = { virtual = "." } dependencies = [ - { name = "cffi" }, + { name = "coincurve" }, + { name = "cryptography" }, + { name = "dnspython" }, + { name = "ecdsa" }, + { name = "embit" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "mnemonic" }, + { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/41/bb668a6e4192303542d2d90c3b38d564af3c17c61bd7d4039af4f29405fe/secp256k1-0.14.0.tar.gz", hash = "sha256:82c06712d69ef945220c8b53c1a0d424c2ff6a1f64aee609030df79ad8383397", size = 2420607, upload-time = "2021-11-06T01:36:10.707Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/09/46/5d3ca364058d39160e3623f0babafe78c2236d359e86924aa07524377c98/secp256k1-0.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f666c67dcf1dc69e1448b2ede5e12aaf382b600204a61dbc65e4f82cea444405", size = 1376587, upload-time = "2022-01-24T12:36:39.127Z" }, - { url = "https://files.pythonhosted.org/packages/ce/e2/5b1616593ed1fa0e07e87b9f5118dc098bd1ddb2a6a7698d82b0ff85ad3f/secp256k1-0.14.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fcabb3c3497a902fb61eec72d1b69bf72747d7bcc2a732d56d9319a1e8322262", size = 1362187, upload-time = "2022-01-24T12:36:41.677Z" }, - { url = "https://files.pythonhosted.org/packages/c9/9c/8148f74dd1fc65d0b97c8cbb101b468e27a2e93a3f291807d0d8ebe4bbf3/secp256k1-0.14.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7a27c479ab60571502516a1506a562d0a9df062de8ad645313fabfcc97252816", size = 1376363, upload-time = "2022-01-24T12:36:44.314Z" }, - { url = "https://files.pythonhosted.org/packages/64/11/ff6d18314bc05a8f66619959a61be2fb6f37793d73e1dd106954b0978d92/secp256k1-0.14.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f4b9306bff6dde020444dfee9ca9b9f5b20ca53a2c0b04898361a3f43d5daf2e", size = 1369109, upload-time = "2022-01-24T12:36:46.782Z" }, + +[package.dev-dependencies] +dev = [ + { name = "black" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, ] -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +[package.metadata] +requires-dist = [ + { name = "coincurve", specifier = ">=20.0.0" }, + { name = "cryptography", specifier = ">=42.0.8" }, + { name = "dnspython", specifier = ">=2.7.0" }, + { name = "ecdsa", specifier = ">=0.19.1" }, + { name = "embit", specifier = ">=0.8.0" }, + { name = "fastapi", specifier = ">=0.115.13" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "mnemonic", specifier = ">=0.21" }, + { name = "pydantic", specifier = ">=1.10.22" }, ] -[[package]] -name = "shortuuid" -version = "1.0.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/e2/bcf761f3bff95856203f9559baf3741c416071dd200c0fc19fad7f078f86/shortuuid-1.0.13.tar.gz", hash = "sha256:3bb9cf07f606260584b1df46399c0b87dd84773e7b25912b7e391e30797c5e72", size = 9662, upload-time = "2024-03-11T20:11:06.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/44/21d6bf170bf40b41396480d8d49ad640bca3f2b02139cd52aa1e272830a5/shortuuid-1.0.13-py3-none-any.whl", hash = "sha256:a482a497300b49b4953e15108a7913244e1bb0d41f9d332f5e9925dba33a3c5a", size = 10529, upload-time = "2024-03-11T20:11:04.807Z" }, +[package.metadata.requires-dev] +dev = [ + { name = "black" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, ] [[package]] @@ -1814,18 +683,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "slowapi" -version = "0.1.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "limits" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" }, -] - [[package]] name = "sniffio" version = "1.3.1" @@ -1835,64 +692,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[package]] -name = "sqlalchemy" -version = "1.4.54" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ce/af/20290b55d469e873cba9d41c0206ab5461ff49d759989b3fe65010f9d265/sqlalchemy-1.4.54.tar.gz", hash = "sha256:4470fbed088c35dc20b78a39aaf4ae54fe81790c783b3264872a0224f437c31a", size = 8470350, upload-time = "2024-09-05T15:54:10.398Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/7f/f7c1e0b65790649bd573f201aa958263a389f336d6e000a569275ff9bd97/SQLAlchemy-1.4.54-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:af00236fe21c4d4f4c227b6ccc19b44c594160cc3ff28d104cdce85855369277", size = 1573472, upload-time = "2024-09-05T17:38:45.351Z" }, - { url = "https://files.pythonhosted.org/packages/e1/da/ff7f0fe50844496db523613979651f076f44da8625b8ad89c503dcff0a52/SQLAlchemy-1.4.54-cp310-cp310-manylinux1_x86_64.manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_5_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1183599e25fa38a1a322294b949da02b4f0da13dbc2688ef9dbe746df573f8a6", size = 1639088, upload-time = "2024-09-05T17:46:37.726Z" }, - { url = "https://files.pythonhosted.org/packages/04/45/3a35bb156aa2fd87b66a4992bb8d65593efd7e16ca2e0597e68c32c29037/SQLAlchemy-1.4.54-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1990d5a6a5dc358a0894c8ca02043fb9a5ad9538422001fb2826e91c50f1d539", size = 1627447, upload-time = "2024-09-05T17:45:32.379Z" }, - { url = "https://files.pythonhosted.org/packages/fe/5b/ed36a50e7147d0d090cd8e35de3b18d2c69a3e85df3be5fe42a570d6c331/SQLAlchemy-1.4.54-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:14b3f4783275339170984cadda66e3ec011cce87b405968dc8d51cf0f9997b0d", size = 1639081, upload-time = "2024-09-05T17:46:39.895Z" }, - { url = "https://files.pythonhosted.org/packages/4b/75/bfbdeb5dece7bc98acb414751a62ee43398b34b10133b1853f4282597757/SQLAlchemy-1.4.54-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b24364150738ce488333b3fb48bfa14c189a66de41cd632796fbcacb26b4585", size = 1638975, upload-time = "2024-09-05T17:46:41.569Z" }, - { url = "https://files.pythonhosted.org/packages/f7/62/358a9291d2fc3d51ad50557e126ad5f48200f199878437f7cb38817d607b/SQLAlchemy-1.4.54-cp310-cp310-win32.whl", hash = "sha256:a8a72259a1652f192c68377be7011eac3c463e9892ef2948828c7d58e4829988", size = 1591719, upload-time = "2024-09-05T17:52:26.646Z" }, - { url = "https://files.pythonhosted.org/packages/10/ad/87cd5578efdcef43a08ce4a21448192abf46bf69a5678ac0039e44364914/SQLAlchemy-1.4.54-cp310-cp310-win_amd64.whl", hash = "sha256:b67589f7955924865344e6eacfdcf70675e64f36800a576aa5e961f0008cde2a", size = 1593512, upload-time = "2024-09-05T17:51:21.402Z" }, - { url = "https://files.pythonhosted.org/packages/da/49/fb98983b5568e93696a25fd5bec1b789095b79a72d5f57c6effddaa81d0a/SQLAlchemy-1.4.54-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b05e0626ec1c391432eabb47a8abd3bf199fb74bfde7cc44a26d2b1b352c2c6e", size = 1589301, upload-time = "2024-09-05T19:22:42.197Z" }, - { url = "https://files.pythonhosted.org/packages/03/98/5a81430bbd646991346cb088a2bdc84d1bcd3dbe6b0cfc1aaa898370e5c7/SQLAlchemy-1.4.54-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13e91d6892b5fcb94a36ba061fb7a1f03d0185ed9d8a77c84ba389e5bb05e936", size = 1629553, upload-time = "2024-09-05T17:49:18.846Z" }, - { url = "https://files.pythonhosted.org/packages/f1/17/14e35db2b0d6deaa27691d014addbb0dd6f7e044f7ee465446a3c0c71404/SQLAlchemy-1.4.54-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb59a11689ff3c58e7652260127f9e34f7f45478a2f3ef831ab6db7bcd72108f", size = 1627640, upload-time = "2024-09-05T17:48:01.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/62/335006a8f2c98f704f391e1a0cc01446d1b1b9c198f579f03599f55bd860/SQLAlchemy-1.4.54-cp311-cp311-win32.whl", hash = "sha256:1390ca2d301a2708fd4425c6d75528d22f26b8f5cbc9faba1ddca136671432bc", size = 1591723, upload-time = "2024-09-05T17:53:17.486Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a1/6b4b8c07082920f5445ec65c221fa33baab102aced5dcc2d87a15d3f8db4/SQLAlchemy-1.4.54-cp311-cp311-win_amd64.whl", hash = "sha256:2b37931eac4b837c45e2522066bda221ac6d80e78922fb77c75eb12e4dbcdee5", size = 1593511, upload-time = "2024-09-05T17:51:50.947Z" }, - { url = "https://files.pythonhosted.org/packages/a5/1b/aa9b99be95d1615f058b5827447c18505b7b3f1dfcbd6ce1b331c2107152/SQLAlchemy-1.4.54-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:3f01c2629a7d6b30d8afe0326b8c649b74825a0e1ebdcb01e8ffd1c920deb07d", size = 1589983, upload-time = "2024-09-05T17:39:02.132Z" }, - { url = "https://files.pythonhosted.org/packages/59/47/cb0fc64e5344f0a3d02216796c342525ab283f8f052d1c31a1d487d08aa0/SQLAlchemy-1.4.54-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c24dd161c06992ed16c5e528a75878edbaeced5660c3db88c820f1f0d3fe1f4", size = 1630158, upload-time = "2024-09-05T17:50:13.255Z" }, - { url = "https://files.pythonhosted.org/packages/c0/8b/f45dd378f6c97e8ff9332ff3d03ecb0b8c491be5bb7a698783b5a2f358ec/SQLAlchemy-1.4.54-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5e0d47d619c739bdc636bbe007da4519fc953393304a5943e0b5aec96c9877c", size = 1629232, upload-time = "2024-09-05T17:48:15.514Z" }, - { url = "https://files.pythonhosted.org/packages/0d/3c/884fe389f5bec86a310b81e79abaa1e26e5d78dc10a84d544a6822833e47/SQLAlchemy-1.4.54-cp312-cp312-win32.whl", hash = "sha256:12bc0141b245918b80d9d17eca94663dbd3f5266ac77a0be60750f36102bbb0f", size = 1592027, upload-time = "2024-09-05T17:54:02.253Z" }, - { url = "https://files.pythonhosted.org/packages/01/c3/c690d037be57efd3a69cde16a2ef1bd2a905dafe869434d33836de0983d0/SQLAlchemy-1.4.54-cp312-cp312-win_amd64.whl", hash = "sha256:f941aaf15f47f316123e1933f9ea91a6efda73a161a6ab6046d1cde37be62c88", size = 1593827, upload-time = "2024-09-05T17:52:07.454Z" }, -] - -[[package]] -name = "sse-starlette" -version = "2.3.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8c/f4/989bc70cb8091eda43a9034ef969b25145291f3601703b82766e5172dfed/sse_starlette-2.3.6.tar.gz", hash = "sha256:0382336f7d4ec30160cf9ca0518962905e1b69b72d6c1c995131e0a703b436e3", size = 18284, upload-time = "2025-05-30T13:34:12.914Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/05/78850ac6e79af5b9508f8841b0f26aa9fd329a1ba00bf65453c2d312bcc8/sse_starlette-2.3.6-py3-none-any.whl", hash = "sha256:d49a8285b182f6e2228e2609c350398b2ca2c36216c2675d875f81e93548f760", size = 10606, upload-time = "2025-05-30T13:34:11.703Z" }, -] - [[package]] name = "starlette" -version = "0.46.2" +version = "0.47.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/69/662169fdb92fb96ec3eaee218cf540a629d629c86d7993d9651226a6789b/starlette-0.47.1.tar.gz", hash = "sha256:aef012dd2b6be325ffa16698f9dc533614fb1cebd593a906b90dc1025529a79b", size = 2583072, upload-time = "2025-06-21T04:03:17.337Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/82/95/38ef0cd7fa11eaba6a99b3c4f5ac948d8bc6ff199aabd327a29cc000840c/starlette-0.47.1-py3-none-any.whl", hash = "sha256:5e11c9f5c7c3f24959edbf2dffdc01bba860228acf657129467d8a7468591527", size = 72747, upload-time = "2025-06-21T04:03:15.705Z" }, ] -[[package]] -name = "tlv8" -version = "0.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/89/6df40b0c5fd9a1c30711f569bd31446f89d1de6d23948b391775f6784d94/tlv8-0.10.0.tar.gz", hash = "sha256:7930a590267b809952272ac2a27ee81b99ec5191fa2eba08050e0daee4262684", size = 16054, upload-time = "2022-04-12T07:47:19.102Z" } - [[package]] name = "tomli" version = "2.2.1" @@ -1922,40 +734,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] -[[package]] -name = "tornado" -version = "6.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821, upload-time = "2025-08-08T18:27:00.78Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563, upload-time = "2025-08-08T18:26:42.945Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729, upload-time = "2025-08-08T18:26:44.473Z" }, - { url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295, upload-time = "2025-08-08T18:26:46.021Z" }, - { url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644, upload-time = "2025-08-08T18:26:47.625Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878, upload-time = "2025-08-08T18:26:50.599Z" }, - { url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549, upload-time = "2025-08-08T18:26:51.864Z" }, - { url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973, upload-time = "2025-08-08T18:26:53.625Z" }, - { url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954, upload-time = "2025-08-08T18:26:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023, upload-time = "2025-08-08T18:26:56.677Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427, upload-time = "2025-08-08T18:26:57.91Z" }, - { url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" }, -] - -[[package]] -name = "typer" -version = "0.16.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/78/d90f616bf5f88f8710ad067c1f8705bf7618059836ca084e5bb2a0855d75/typer-0.16.1.tar.gz", hash = "sha256:d358c65a464a7a90f338e3bb7ff0c74ac081449e53884b12ba658cbd72990614", size = 102836, upload-time = "2025-08-18T19:18:22.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/76/06dbe78f39b2203d2a47d5facc5df5102d0561e2807396471b5f7c5a30a1/typer-0.16.1-py3-none-any.whl", hash = "sha256:90ee01cb02d9b8395ae21ee3368421faf21fa138cb2a541ed369c08cec5237c9", size = 46397, upload-time = "2025-08-18T19:18:21.663Z" }, -] - [[package]] name = "typing-extensions" version = "4.14.0" @@ -1965,55 +743,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, ] -[[package]] -name = "urllib3" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.34.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" }, -] - -[[package]] -name = "uvloop" -version = "0.21.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/76/44a55515e8c9505aa1420aebacf4dd82552e5e15691654894e90d0bd051a/uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f", size = 1442019, upload-time = "2024-10-14T23:37:20.068Z" }, - { url = "https://files.pythonhosted.org/packages/35/5a/62d5800358a78cc25c8a6c72ef8b10851bdb8cca22e14d9c74167b7f86da/uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d", size = 801898, upload-time = "2024-10-14T23:37:22.663Z" }, - { url = "https://files.pythonhosted.org/packages/f3/96/63695e0ebd7da6c741ccd4489b5947394435e198a1382349c17b1146bb97/uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26", size = 3827735, upload-time = "2024-10-14T23:37:25.129Z" }, - { url = "https://files.pythonhosted.org/packages/61/e0/f0f8ec84979068ffae132c58c79af1de9cceeb664076beea86d941af1a30/uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb", size = 3825126, upload-time = "2024-10-14T23:37:27.59Z" }, - { url = "https://files.pythonhosted.org/packages/bf/fe/5e94a977d058a54a19df95f12f7161ab6e323ad49f4dabc28822eb2df7ea/uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f", size = 3705789, upload-time = "2024-10-14T23:37:29.385Z" }, - { url = "https://files.pythonhosted.org/packages/26/dd/c7179618e46092a77e036650c1f056041a028a35c4d76945089fcfc38af8/uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c", size = 3800523, upload-time = "2024-10-14T23:37:32.048Z" }, - { url = "https://files.pythonhosted.org/packages/57/a7/4cf0334105c1160dd6819f3297f8700fda7fc30ab4f61fbf3e725acbc7cc/uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8", size = 1447410, upload-time = "2024-10-14T23:37:33.612Z" }, - { url = "https://files.pythonhosted.org/packages/8c/7c/1517b0bbc2dbe784b563d6ab54f2ef88c890fdad77232c98ed490aa07132/uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0", size = 805476, upload-time = "2024-10-14T23:37:36.11Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ea/0bfae1aceb82a503f358d8d2fa126ca9dbdb2ba9c7866974faec1cb5875c/uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e", size = 3960855, upload-time = "2024-10-14T23:37:37.683Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ca/0864176a649838b838f36d44bf31c451597ab363b60dc9e09c9630619d41/uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb", size = 3973185, upload-time = "2024-10-14T23:37:40.226Z" }, - { url = "https://files.pythonhosted.org/packages/30/bf/08ad29979a936d63787ba47a540de2132169f140d54aa25bc8c3df3e67f4/uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6", size = 3820256, upload-time = "2024-10-14T23:37:42.839Z" }, - { url = "https://files.pythonhosted.org/packages/da/e2/5cf6ef37e3daf2f06e651aae5ea108ad30df3cb269102678b61ebf1fdf42/uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d", size = 3937323, upload-time = "2024-10-14T23:37:45.337Z" }, - { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload-time = "2024-10-14T23:37:47.833Z" }, - { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload-time = "2024-10-14T23:37:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload-time = "2024-10-14T23:37:51.703Z" }, - { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload-time = "2024-10-14T23:37:54.122Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload-time = "2024-10-14T23:37:55.766Z" }, - { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload-time = "2024-10-14T23:37:58.195Z" }, -] - [[package]] name = "virtualenv" version = "20.34.0" @@ -2028,173 +757,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615 wheels = [ { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, ] - -[[package]] -name = "websocket-client" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648, upload-time = "2024-04-23T22:16:16.976Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826, upload-time = "2024-04-23T22:16:14.422Z" }, -] - -[[package]] -name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, - { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, - { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, - { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, - { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, - { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, - { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, - { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, - { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, - { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, - { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, - { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, - { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, - { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, - { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, - { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, - { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, - { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, -] - -[[package]] -name = "win32-setctime" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, -] - -[[package]] -name = "wrapt" -version = "1.17.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, - { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, - { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, - { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, - { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, - { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, - { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, - { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, - { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, - { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, - { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, - { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, - { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, - { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, - { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, - { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, - { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, - { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, - { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, - { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, -] - -[[package]] -name = "yarl" -version = "1.20.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/65/7fed0d774abf47487c64be14e9223749468922817b5e8792b8a64792a1bb/yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4", size = 132910, upload-time = "2025-06-10T00:42:31.108Z" }, - { url = "https://files.pythonhosted.org/packages/8a/7b/988f55a52da99df9e56dc733b8e4e5a6ae2090081dc2754fc8fd34e60aa0/yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a", size = 90644, upload-time = "2025-06-10T00:42:33.851Z" }, - { url = "https://files.pythonhosted.org/packages/f7/de/30d98f03e95d30c7e3cc093759982d038c8833ec2451001d45ef4854edc1/yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed", size = 89322, upload-time = "2025-06-10T00:42:35.688Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7a/f2f314f5ebfe9200724b0b748de2186b927acb334cf964fd312eb86fc286/yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e", size = 323786, upload-time = "2025-06-10T00:42:37.817Z" }, - { url = "https://files.pythonhosted.org/packages/15/3f/718d26f189db96d993d14b984ce91de52e76309d0fd1d4296f34039856aa/yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73", size = 319627, upload-time = "2025-06-10T00:42:39.937Z" }, - { url = "https://files.pythonhosted.org/packages/a5/76/8fcfbf5fa2369157b9898962a4a7d96764b287b085b5b3d9ffae69cdefd1/yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e", size = 339149, upload-time = "2025-06-10T00:42:42.627Z" }, - { url = "https://files.pythonhosted.org/packages/3c/95/d7fc301cc4661785967acc04f54a4a42d5124905e27db27bb578aac49b5c/yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8", size = 333327, upload-time = "2025-06-10T00:42:44.842Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/e21269718349582eee81efc5c1c08ee71c816bfc1585b77d0ec3f58089eb/yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23", size = 326054, upload-time = "2025-06-10T00:42:47.149Z" }, - { url = "https://files.pythonhosted.org/packages/32/ae/8616d1f07853704523519f6131d21f092e567c5af93de7e3e94b38d7f065/yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70", size = 315035, upload-time = "2025-06-10T00:42:48.852Z" }, - { url = "https://files.pythonhosted.org/packages/48/aa/0ace06280861ef055855333707db5e49c6e3a08840a7ce62682259d0a6c0/yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb", size = 338962, upload-time = "2025-06-10T00:42:51.024Z" }, - { url = "https://files.pythonhosted.org/packages/20/52/1e9d0e6916f45a8fb50e6844f01cb34692455f1acd548606cbda8134cd1e/yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2", size = 335399, upload-time = "2025-06-10T00:42:53.007Z" }, - { url = "https://files.pythonhosted.org/packages/f2/65/60452df742952c630e82f394cd409de10610481d9043aa14c61bf846b7b1/yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30", size = 338649, upload-time = "2025-06-10T00:42:54.964Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f5/6cd4ff38dcde57a70f23719a838665ee17079640c77087404c3d34da6727/yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309", size = 358563, upload-time = "2025-06-10T00:42:57.28Z" }, - { url = "https://files.pythonhosted.org/packages/d1/90/c42eefd79d0d8222cb3227bdd51b640c0c1d0aa33fe4cc86c36eccba77d3/yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24", size = 357609, upload-time = "2025-06-10T00:42:59.055Z" }, - { url = "https://files.pythonhosted.org/packages/03/c8/cea6b232cb4617514232e0f8a718153a95b5d82b5290711b201545825532/yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13", size = 350224, upload-time = "2025-06-10T00:43:01.248Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a3/eaa0ab9712f1f3d01faf43cf6f1f7210ce4ea4a7e9b28b489a2261ca8db9/yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8", size = 81753, upload-time = "2025-06-10T00:43:03.486Z" }, - { url = "https://files.pythonhosted.org/packages/8f/34/e4abde70a9256465fe31c88ed02c3f8502b7b5dead693a4f350a06413f28/yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16", size = 86817, upload-time = "2025-06-10T00:43:05.231Z" }, - { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833, upload-time = "2025-06-10T00:43:07.393Z" }, - { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070, upload-time = "2025-06-10T00:43:09.538Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818, upload-time = "2025-06-10T00:43:11.575Z" }, - { url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003, upload-time = "2025-06-10T00:43:14.088Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537, upload-time = "2025-06-10T00:43:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358, upload-time = "2025-06-10T00:43:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362, upload-time = "2025-06-10T00:43:20.888Z" }, - { url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979, upload-time = "2025-06-10T00:43:23.169Z" }, - { url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274, upload-time = "2025-06-10T00:43:27.111Z" }, - { url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294, upload-time = "2025-06-10T00:43:28.96Z" }, - { url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169, upload-time = "2025-06-10T00:43:30.701Z" }, - { url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776, upload-time = "2025-06-10T00:43:32.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341, upload-time = "2025-06-10T00:43:34.543Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988, upload-time = "2025-06-10T00:43:36.489Z" }, - { url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113, upload-time = "2025-06-10T00:43:38.592Z" }, - { url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485, upload-time = "2025-06-10T00:43:41.038Z" }, - { url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686, upload-time = "2025-06-10T00:43:42.692Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, - { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, - { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, - { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, - { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, - { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, - { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, - { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, - { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, - { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, - { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, - { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, - { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, - { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, -] diff --git a/views.py b/views.py index 8a2844f..5662cab 100644 --- a/views.py +++ b/views.py @@ -4,16 +4,15 @@ from lnbits.decorators import check_user_exists from lnbits.helpers import template_renderer -example_ext_generic = APIRouter(tags=["example"]) +silnt_generic_router = APIRouter() -@example_ext_generic.get( - "/", description="Example generic endpoint", response_class=HTMLResponse -) -async def index( - request: Request, - user: User = Depends(check_user_exists), -): - return template_renderer(["example/templates"]).TemplateResponse( - request, "example/index.html", {"user": user.json()} +def silnt_renderer(): + return template_renderer(["siLNt/templates"]) + + +@silnt_generic_router.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return silnt_renderer().TemplateResponse( + "silnt/index.html", {"request": request, "user": user.json()} ) diff --git a/views_api.py b/views_api.py index 021227c..1efefd0 100644 --- a/views_api.py +++ b/views_api.py @@ -1,35 +1,3337 @@ +import asyncio +import json from http import HTTPStatus - +from base64 import b64encode import httpx -from fastapi import APIRouter, Depends, HTTPException -from lnbits.decorators import require_invoice_key +import hashlib +import re +import secrets +import time +import time as _time +from .helpers.wallet import ( + generate_silent_wallet_address, + decrypt_mnemonic, + build_transaction, + generate_labeled_sp_address, + get_spend_pub_from_secret, +) +from .helpers.scan import scan_wallet, get_scan_progress, request_scan_stop, get_tx_status +from .helpers.address_resolver import bip353_resolve +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, Cookie +from lnbits.core.models import WalletTypeInfo +from lnbits.decorators import require_admin_key, require_invoice_key +from lnbits.helpers import urlsafe_short_hash +from lnbits.settings import settings as lnbits_settings +from loguru import logger +from typing import Optional +from .helpers.bip353_cloudflare import ( + create_bip353_record, + get_zone_domain, + delete_bip353_record, + CloudflareError +) +from .helpers.email_verification import ( + RegistrationRequest, VerifyRegistrationRequest, + start_registration, complete_registration, +) +from mnemonic import Mnemonic +from .helpers.dust_check import evaluate_dust_for_wallet +from .helpers.scan_rate_limiter import check_scan_allowed, mark_scan_finished +from .helpers.forgot_password import request_password_reset +from .helpers.transactions import get_wallet_transaction_detail, list_wallet_transactions +from lnbits.core.crud import get_account, get_account_by_username +from lnbits.core.crud.users import delete_account +from lnbits.core.services.notifications import send_email_notification +from .helpers.device_auth import ( + require_trusted_device, + require_trusted_device_admin, + set_device_cookie, + clear_device_cookie, + read_device_id, + get_client_ip, + cookie_name_for_user, + MAX_TRUSTED_DEVICES_PER_USER +) +from .helpers.user import is_lnbits_admin, require_admin, validate_born_height +from .helpers.scan import BlindBitOracleClient +from .helpers.fee_rates_backend import get_recommended_fees, get_btc_usd_rate +from .helpers.payjoin_wallet import sync_wallet, next_unused_receive_index +from .helpers.payjoin_merge import build_merged_payjoin +from .helpers.psbt_combine import combine_and_finalize +from .helpers.electrum_client import ElectrumClient +from .crud import ( + get_silnt_wallets, + create_silnt_wallet, + delete_silnt_wallet, + delete_utxos_for_wallet, + get_sp_address, + get_hr_address, + update_hr_address, + update_last_height, + update_title, + update_balance, + get_silnt_wallet, + get_backend_config, + update_backend_config, + get_utxos_for_wallet, + insert_utxos_for_wallet, + update_unconfirmed_utxo, + save_wallet_address, + get_wallet_addresses, + count_wallet_addresses, + insert_wallet_address, + delete_wallet_label_address, + delete_wallet_label_addresses, + get_cloudflare_config, + update_cloudflare_config, + count_silnt_wallets, + address_exists, + update_utxo_label_by_txid, + get_utxos_by_txid, + get_next_label_index, + label_index_taken, + owner_check_dust, + update_utxo_frozen, + get_eligible_utxos, + update_address_label, + get_wallet_address, + mark_utxos_spent_by_tx, + set_utxo_freeze_manual, + clear_utxo_freeze_manual, + count_trusted_devices, + list_trusted_devices, + add_trusted_device, + get_trusted_device, + touch_trusted_device, + revoke_trusted_device, + revoke_all_other_devices, + get_user_prefs, + upsert_user_prefs, + get_effective_dust_threshold, + get_wallet_active_request, + get_wallet_last_rejected_request, + create_bip353_request, + get_bip353_request, + list_user_bip353_requests, + list_pending_bip353_requests, + is_username_taken, + is_username_approved_elsewhere, + sp_address_has_approved_bitmail, + update_bip353_request_status, + cancel_user_request, + restore_utxo_to_unspent, + get_wallet_unspent_balance, + get_utxo, + delete_all_silnt_data_for_user, + get_user_hr_addresses, + clear_wallet_hr_address, + count_approved_bip353_for_wallet, + address_has_approved_bitmail, + update_label_hr_address, + clear_label_hr_address, + mark_utxos_confirmed_spent_by_tx, + cancel_pending_request_for_address, + cancel_all_pending_requests_for_wallet, + list_all_bip353_requests, + delete_bip353_request_if_terminal, + delete_terminal_bip353_requests, + delete_bip353_requests_for_wallet, + create_payjoin_descriptor, + get_payjoin_descriptor, + list_payjoin_descriptors, + delete_payjoin_descriptor, + derive_descriptor_address, + get_payjoin_request, + update_payjoin_request, + list_silnt_user_ids_for_network, + list_payjoin_requests_for_receiver, + list_payjoin_requests_for_sender, + get_reserved_outpoints, + create_payjoin_invoice, list_payjoin_invoices_for_payer, + get_account_id_by_email, + create_payjoin_contact, + get_payjoin_contact, + set_payjoin_contact_status, + delete_payjoin_contact, + list_payjoin_contacts, + list_accepted_contact_user_ids, + list_accepted_contacts_with_ids, + set_payjoin_contact_label, + get_payjoin_contact_labels, + create_sp_contact, + list_sp_contacts, + update_sp_contact_label, + delete_sp_contact, + touch_sp_contact, + list_silnt_user_ids, + get_ntfy_config, + update_ntfy_config, + send_ntfy_notification, + notify_service_health_change, + create_admin_alert, + list_admin_alerts, + count_open_admin_alerts, + acknowledge_admin_alert, + delete_admin_alerts_for_wallet, + get_issued_bitmail_sp_address, + list_approved_bitmails, + open_alert_exists_for, + open_tamper_notified, + mark_tamper_notified, + resolve_open_alerts_for, + tamper_signature_alerted, + should_send_login_alert, + create_device_code, + verify_device_code, + mark_self_revoke, + in_self_revoke_grace, + DEFAULT_CONFIG_NETWORK +) + +from .models import ( + BackendConfig, + CreateWallet, + WalletAccount, + BuildTxRequest, + BroadcastTxRequest, + Config, + ScanWalletRequest, + SaveAddressRequest, + PreviewAddressRequest, + CloudflareConfig, + SetupBip353Request, + RecoverKeysRequest, + ForgotPasswordRequest, + DeviceVerifyCodeRequest, + UpdateUtxoLabel, + UpdateUtxoFrozenRequest, + UpdateAddressLabelRequest, + TrustedDevice, + DeviceCheckResponse, + DeviceConfirmResponse, + DeviceListResponse, + WhoamiResponse, + UserPrefs, + UpdateUserPrefsRequest, + CreateBip353Request, + ApproveBip353Request, + RejectBip353Request, + RestoreUtxoRequest, + ImportDescriptorData, + CreateInvoiceData, + PayInvoiceData, + SignPayjoinData, + CreateContactData, + ContactLabelData, + CreateSpContactData, + UpdateSpContactData, + AdminDeleteAccountData, + NtfyConfig, + USERNAME_PATTERN, + RESERVED_USERNAMES, + RECENT_REJECT_COOLDOWN, +) + +MAX_ADDRESSES_PER_WALLET = 2 +BIP352_CHANGE_LABEL_INDEX = 1 +BITMAIL_MAX_ACQUISITIONS = 3 +BLINDBIT_SYNC_TOLERANCE = 2 +FULCRUM_SYNC_TOLERANCE = 2 + +silnt_api_router = APIRouter() + +_BITMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +# ── Wallets ────────────────────────────────────────────────────────────────── + + +@silnt_api_router.get("/api/v1/wallet", status_code=HTTPStatus.OK) +async def api_wallets_retrieve( + network: Optional[str] = Query(None), + key_info: WalletTypeInfo = Depends(require_trusted_device), +) -> list[WalletAccount]: + return await get_silnt_wallets(key_info.wallet.user, network) + + +@silnt_api_router.get( + "/api/v1/wallet/{wallet_id}", dependencies=[Depends(require_trusted_device)] +) +async def api_wallet_retrieve(wallet_id: str) -> WalletAccount: + silnt_wallet = await get_silnt_wallet(wallet_id) + if not silnt_wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) + return silnt_wallet + + +@silnt_api_router.post("/api/v1/wallet", status_code=HTTPStatus.OK) +async def api_wallet_create( + data: CreateWallet, key_info: WalletTypeInfo = Depends(require_trusted_device) +) -> dict: + try: + wallet_id = urlsafe_short_hash() + + blindbit_cfg = await get_backend_config(data.network) + min_height = blindbit_cfg.min_scan_height or 0 + + # ★ 1. Default last_height to oracle tip if not supplied + last_height = data.last_height + if last_height is None: + try: + oracle = BlindBitOracleClient(base_url=blindbit_cfg.blindbit_url) + tip = await oracle.get_chain_tip() # ← your existing tip helper + last_height = int(tip) if tip else 0 + except Exception as e: + logger.warning(f"Could not fetch oracle tip; defaulting to 0: {e}") + last_height = 0 + else: + last_height = int(data.last_height) + + # Clamp to configured minimum + if min_height > 0 and last_height < min_height: + # If the user explicitly entered a too-low height, error. + # If we auto-defaulted to tip, tip is always >= min so no error. + if data.last_height is not None: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=( + f"Wallet birth height must be at least {min_height} on this server. " + f"You entered {data.last_height}." + ), + ) + last_height = min_height + + # ★ 2. Generate or import + ★ 3. checksum validation + # data.mnemonic is encrypted (as before) when importing. Decrypt first, + # then validate/generate. If no mnemonic supplied → generate fresh. + mn = Mnemonic("english") + if data.mnemonic: # ← only decrypt when present + mnemonic_plain = decrypt_mnemonic(data.mnemonic, str(last_height)) + words = mnemonic_plain.strip().lower().split() + if len(words) != 12: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail=f"Mnemonic must be exactly 12 words (got {len(words)}).") + mnemonic_plain = " ".join(words) + if not mn.check(mnemonic_plain): + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail="Invalid mnemonic — the checksum (last word) is incorrect.") + was_generated = False + else: + mnemonic_plain = mn.generate(strength=128) # fresh 12 words + was_generated = True + + # ★ 4. Pass the BIP-39 passphrase through to derivation + passphrase = (data.passphrase or "").strip() + + new_wallet = WalletAccount( + id=wallet_id, + user=key_info.wallet.user, + title=data.title, + balance=0, + hr_address=data.hr_address or "", + network=data.network, + last_height=last_height, + sp_address="", + spend_key="", + scan_secret="", + ) + + ( + sp_address, + scan_secret_hex, + spend_key_hex, + ) = await generate_silent_wallet_address( + mnemonic_plain, + passphrase=passphrase, # ← see note if your helper lacks this arg + network=data.network, + ) + if not all([sp_address, scan_secret_hex, spend_key_hex]): + raise ValueError( + f"Wallet '{data.title}' cannot be created with given mnemonic!" + ) + + _stable_seed = f"{data.network}:{sp_address}".encode() + wallet_id = "sp" + hashlib.sha256(_stable_seed).hexdigest()[:20] + new_wallet.id = wallet_id + + wallets = await get_silnt_wallets(key_info.wallet.user, data.network) + if any(w.sp_address == sp_address for w in wallets): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Silent Payment Wallet already exists!", + ) + + # The limit is per-network: max_wallets_per_user comes from THIS network's + # backend config, so count only the user's wallets on this network. A user + # at the cap on signet can still create a wallet on mainnet. + max_wallets = blindbit_cfg.max_wallets_per_user or 1 + if max_wallets > 0: + current_count = await count_silnt_wallets(key_info.wallet.user, data.network) + if current_count >= max_wallets: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=( + f"Wallet limit reached. You can have at most {max_wallets} wallet" + f"{'s' if max_wallets != 1 else ''} on {data.network}. " + f"You currently have {current_count}." + ), + ) + + if data.hr_address: + try: + resolved = bip353_resolve(data.hr_address) + result = resolved.get("result", "") + result = result.replace("bitcoin:?sp=", "").replace("sp=", "").strip() + if result.lower() != sp_address.lower(): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="BIP353 address resolves to a different SP address than the wallet's.", + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"BIP353 resolution failed for {data.hr_address}: {str(e)}", + ) + + new_wallet.sp_address = sp_address + await create_silnt_wallet(new_wallet) + + except HTTPException: + raise + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) + ) from exc + + return { + "wallet_id": wallet_id, + "sp_address": sp_address, + "scan_secret": scan_secret_hex, # client must store securely + "spend_key": spend_key_hex, # client must store securely + # ★ Return the mnemonic so the client can show it once (esp. on generate) + "mnemonic": mnemonic_plain, + "passphrase": passphrase if passphrase else None, + "last_height": last_height, + "network": data.network, + "generated": was_generated, + } + + +@silnt_api_router.put("/api/v1/wallet/{wallet_id}", status_code=HTTPStatus.OK) +async def api_wallet_update( + wallet_id: str, + data: CreateWallet, + key_info: WalletTypeInfo = Depends(require_trusted_device), +) -> dict: + try: + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) + if data.hr_address is not None and data.hr_address != wallet.hr_address: + # Validate BIP353 resolves to this wallet's SP address + try: + resolved = bip353_resolve(data.hr_address) + result = resolved.get("result", "") + result = result.replace("bitcoin:?sp=", "").replace("sp=", "").strip() + if result.lower() != wallet.sp_address.lower(): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"BIP353 address resolves to a different SP address than this wallet's.", + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"BIP353 resolution failed for {data.hr_address}: {str(e)}", + ) + await update_hr_address(wallet.id, data.hr_address) + if data.last_height is not None and int(data.last_height) != wallet.last_height: + validated_height = await validate_born_height(data.last_height, wallet.network) + if validated_height is not None: + await update_last_height(wallet.id, int(data.last_height)) + if data.title is not None and data.title != wallet.title: + await update_title(wallet.id, data.title) + if data.balance is not None and data.balance != wallet.balance: + await update_balance(wallet.id, data.balance) + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) + ) from exc + return {} + + +@silnt_api_router.delete( + "/api/v1/wallet/{wallet_id}", dependencies=[Depends(require_trusted_device)] +) +async def api_wallet_delete(wallet_id: str): + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) + # Clean up BitMail state before wiping the wallet: + # 1. Cancel all pending requests so none linger in the admin queue. + try: + await cancel_all_pending_requests_for_wallet(wallet_id) + except Exception as e: + logger.warning(f"Could not cancel pending requests for deleted wallet {wallet_id}: {e}") + + # 2. Remove DNS records for any live BitMails (base + labeled) on this wallet. + cf = await get_cloudflare_config() + if cf and getattr(cf, "api_token", "") and getattr(cf, "zone_id", ""): + hrs = [] + if (wallet.hr_address or "").strip(): + hrs.append(wallet.hr_address.strip()) + try: + for a in await get_wallet_addresses(wallet_id): + ahr = (a.get("hr_address") if isinstance(a, dict) else getattr(a, "hr_address", None)) or "" + if ahr.strip(): + hrs.append(ahr.strip()) + except Exception as e: + logger.warning(f"Could not enumerate labeled BitMails for {wallet_id}: {e}") + for hr in hrs: + if "@" in hr: + try: + await delete_bip353_record(cf.api_token, cf.zone_id, cf.domain, hr.split("@", 1)[0]) + logger.info(f"Removed BitMail DNS {hr} for deleted wallet {wallet_id}") + except Exception as e: + logger.warning(f"Could not remove BitMail DNS {hr} for deleted wallet {wallet_id}: {e}") + await delete_silnt_wallet(wallet_id) + await delete_utxos_for_wallet(wallet_id) + await delete_wallet_label_addresses(wallet_id) + # Purge records that reference this wallet by id so nothing is left orphaned: + # its BitMail requests (else the tamper sweep keeps checking a dead wallet and + # list_approved_bitmails returns it forever) and any admin alerts raised for it. + try: + await delete_bip353_requests_for_wallet(wallet_id) + await delete_admin_alerts_for_wallet(wallet_id) + except Exception as e: + logger.warning(f"post-delete cleanup (bip353/alerts) failed for {wallet_id}: {e}") + return "", HTTPStatus.NO_CONTENT + + +@silnt_api_router.get("/api/v1/wallet/{wallet_id}/addresses") +async def api_get_wallet_addresses( + wallet_id: str, key_info: WalletTypeInfo = Depends(require_trusted_device) +): + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) + if wallet.user != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied.") + addresses = await get_wallet_addresses(wallet_id) + return {"addresses": addresses, "max": MAX_ADDRESSES_PER_WALLET} + + +@silnt_api_router.post("/api/v1/wallet/{wallet_id}/addresses/preview") +async def api_preview_wallet_address( + wallet_id: str, + data: PreviewAddressRequest, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) + if wallet.user != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied.") + + label_index = data.label_index + if label_index is None or label_index <= 0: + label_index = await get_next_label_index(wallet_id) + elif label_index == BIP352_CHANGE_LABEL_INDEX: + # m=1 is reserved per BIP-352 — silently bump to next free + label_index = await get_next_label_index(wallet_id) + elif await label_index_taken(wallet_id, label_index): + label_index = await get_next_label_index(wallet_id) + spend_pub_hex = get_spend_pub_from_secret(data.spend_key) + hrp = "sp" if wallet.network == "mainnet" else "tsp" + + try: + sp_address = generate_labeled_sp_address( + scan_secret_hex=data.scan_secret, + spend_pub_hex=spend_pub_hex, + m=label_index, + hrp=hrp, + ) + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Failed to generate address: {str(e)}", + ) + return { + "sp_address": sp_address, + "label_index": label_index, + } -from .models import Example -example_ext_api = APIRouter() +@silnt_api_router.delete("/api/v1/wallet/{wallet_id}/addresses/{address_id}") +async def api_delete_wallet_address( + wallet_id: str, + address_id: str, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) + if wallet.user != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied.") + # Clean up this address's BitMail state before deleting the row, so we don't + # (a) orphan a pending request in the admin queue, or (b) leave a live DNS + # record bound to an address that no longer exists. + addr = await get_wallet_address(address_id) + + # 1. Cancel any pending BitMail request for this address. + try: + await cancel_pending_request_for_address(wallet_id, address_id, sp_address=(getattr(addr, "sp_address", None) if addr else None)) + except Exception as e: + logger.warning(f"Could not cancel pending request for deleted address {address_id}: {e}") + + # 2. If the address had an approved BitMail, remove its DNS record. The + # approved bip353_requests row is intentionally LEFT intact: it preserves + # the wallet's lifetime BitMail cap and keeps the username reserved. + hr = (addr.hr_address or "").strip() if addr else "" + if hr and "@" in hr: + cf = await get_cloudflare_config() + if cf and getattr(cf, "api_token", "") and getattr(cf, "zone_id", ""): + try: + await delete_bip353_record(cf.api_token, cf.zone_id, cf.domain, hr.split("@", 1)[0]) + logger.info(f"Removed BitMail DNS {hr} for deleted labeled address {address_id}") + except Exception as e: + logger.warning(f"Could not remove BitMail DNS for deleted address {address_id}: {e}") + await delete_wallet_label_address(address_id, wallet_id) + return "", HTTPStatus.NO_CONTENT -# views_api.py is for you API endpoints that could be hit by another service +@silnt_api_router.post("/api/v1/wallet/{wallet_id}/addresses") +async def api_save_generated_wallet_address( + wallet_id: str, + data: SaveAddressRequest, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) + if wallet.user != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied.") -@example_ext_api.get("/api/v1/test/{example_data}", description="Example API endpoint") -async def api_example(example_data: str) -> Example: - if example_data != "00000000": + count = await count_wallet_addresses(wallet_id) + if count >= MAX_ADDRESSES_PER_WALLET: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, - detail="Invalid example data", + detail=f"Maximum of {MAX_ADDRESSES_PER_WALLET} addresses per wallet reached.", + ) + if await address_exists(wallet_id, data.sp_address): + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail="This address is already saved on this wallet. Generate a new one.", ) - # Do some python things and return the data - return Example(id="2", wallet=example_data) + # Determine the label_index — trust server, not stale client state + label_index = data.label_index + if label_index is None or label_index <= 0: + label_index = await get_next_label_index(wallet_id) + elif await label_index_taken(wallet_id, label_index): + # Bump to next free instead of erroring + label_index = await get_next_label_index(wallet_id) + if label_index == BIP352_CHANGE_LABEL_INDEX: + # m=1 is reserved for change — bump to next free + label_index = await get_next_label_index(wallet_id) + try: + return await save_wallet_address( + wallet_id = wallet_id, + sp_address = data.sp_address, + label = data.label, + label_index = label_index, + ) + except Exception as e: + logger.error(f"save_wallet_address failed: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Could not save labeled address. Please contact your administrator.", + ) -@example_ext_api.get( - "/api/v1/vetted", - description="Get the vetted extension readme", - dependencies=[Depends(require_invoice_key)], + +@silnt_api_router.get( + "/api/v1/oracle/tip", + dependencies=[Depends(require_trusted_device)], ) -async def api_get_vetted(): - async with httpx.AsyncClient() as client: - resp = await client.get( - "https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/README.md" +async def api_get_chain_tip(network: Optional[str] = Query(None)): + # No silent signet fallback: a client that omits `network` (e.g. a mainnet + # build) would otherwise get signet's tip. Require it explicitly. + if not network: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="A `network` query parameter is required (e.g. ?network=mainnet).", + ) + blindbit = await get_backend_config(network) + if not blindbit.blindbit_url: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="BlindBit Oracle URL not configured.", + ) + headers = {} + try: + async with httpx.AsyncClient(timeout=10.0, verify=False) as client: + resp = await client.get( + f"{blindbit.blindbit_url.rstrip('/')}/info", headers=headers + ) + resp.raise_for_status() + return resp.json() + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, + detail=f"Could not reach BlindBit Oracle: {str(e)}", ) - return resp.text + + +# ── Backend config ─────────────────────────────────────────────────────────── + + +@silnt_api_router.get("/api/v1/backend/config") +async def api_get_backend_config( + network: Optional[str] = Query(None), + key_info: WalletTypeInfo = Depends(require_trusted_device), +) -> BackendConfig: + """Any authenticated user can read the blindbit endpoint (needed to trigger scan). + Credentials (user/pass) are included so the scan proxy can use them server-side.""" + return await get_backend_config(network or DEFAULT_CONFIG_NETWORK) + + +@silnt_api_router.put("/api/v1/backend/config") +async def api_update_backend_config( + data: BackendConfig, network: Optional[str] = Query(None), + key_info: WalletTypeInfo = Depends(require_trusted_device_admin) +) -> BackendConfig: + require_admin(key_info) + """Only admin can write blindbit connection settings.""" + return await update_backend_config(data, network or DEFAULT_CONFIG_NETWORK) + + +# ── Scanning ────────────────────────────────────────────────────────────────── + + +@silnt_api_router.get( + "/api/v1/utxos", + description="Fetch UTXOs from blindbit", + dependencies=[Depends(require_trusted_device)], +) +async def api_get_utxos( + wallet_id: str = Query(...), key_info: WalletTypeInfo = Depends(require_trusted_device) +): + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) + utxos = await get_utxos_for_wallet(wallet_id) + return { + "utxos": [u.dict() for u in utxos], + } + + +@silnt_api_router.post("/api/v1/wallet/{wallet_id}/scan") +async def api_scan_wallet( + wallet_id: str, + data: ScanWalletRequest, + request: Request, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist.") + if wallet.user != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied.") + + # Enforce min scan height + blindbit = await get_backend_config(wallet.network) + min_height = blindbit.min_scan_height or 0 + + requested_from = data.from_height if data.from_height is not None else wallet.last_height + if min_height > 0 and requested_from < min_height: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=( + f"Scanning below block {min_height} is disabled on this server. " + f"Requested start height was {requested_from}." + ), + ) + + # Estimate block count for budget check + # Use chain tip as fallback if to_height not given + to_height_est = data.to_height + if to_height_est is None: + oracle = BlindBitOracleClient(base_url=blindbit.blindbit_url) + to_height_est = await oracle.get_chain_tip() + estimated_blocks = max(1, to_height_est - requested_from) + + client_ip = request.client.host if request.client else "unknown" + + # Rate limit check (raises 429 if any limit exceeded) + check_scan_allowed( + user_id=key_info.wallet.user, + wallet_id=wallet_id, + ip=client_ip, + estimated_blocks=estimated_blocks, + ) + user_id = key_info.wallet.user + + async def _run_scan(): + result = None + try: + result = await scan_wallet( + wallet_id=wallet_id, + scan_secret_hex=data.scan_secret, + spend_secret_hex=data.spend_key, + from_height=data.from_height, + to_height=data.to_height, + ) + except ValueError as e: + logger.error(f"Scan value error for {wallet_id}: {e}"); _mark_scan_failed(wallet_id) + except RuntimeError as e: + logger.error(f"Scan runtime error for {wallet_id}: {e}"); _mark_scan_failed(wallet_id) + except Exception as e: + logger.error(f"Scan unexpected error for {wallet_id}: {e}", exc_info=True); _mark_scan_failed(wallet_id) + finally: + actual = (result or {}).get("blocks_scanned") if isinstance(result, dict) else None + mark_scan_finished( + user_id, wallet_id, + actual_blocks=actual, + estimated_blocks=estimated_blocks, + ) + + asyncio.create_task(_run_scan()) + return {"started": True} + +@silnt_api_router.post("/api/v1/wallet/{wallet_id}/scan/stop") +async def api_stop_scan( + wallet_id: str, key_info: WalletTypeInfo = Depends(require_trusted_device) +): + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist.") + if wallet.user != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied.") + request_scan_stop(wallet_id) + # Charge only for blocks actually scanned so far, and clear the per-wallet + # cooldown so the user can immediately retry (e.g. fix the range and rescan). + actual = get_scan_progress(wallet_id).get("current", 0) + mark_scan_finished( + key_info.wallet.user, wallet_id, + actual_blocks=actual, + estimated_blocks=None, # not reconciling here; just reset cooldown + reset_wallet_cooldown=True, + ) + return {"status": "stop requested"} + + +# BIP353 +@silnt_api_router.get("/api/v1/bip353/resolve") +async def api_resolve_bip353( + address: str = Query( + ..., description="BIP353 address in email format, e.g. alice@domain.com" + ), + key_info: WalletTypeInfo = Depends(require_trusted_device), +) -> dict: + return bip353_resolve(address.strip()) + + +@silnt_api_router.get("/api/v1/bip353/available") +async def api_bip353_available( + username: str = Query(..., description="Desired BitMail username (no domain)"), + key_info: WalletTypeInfo = Depends(require_trusted_device), +) -> dict: + """ + Live availability check for a BitMail username, used by clients before + submitting a request. Applies the same rules as api_create_bip353_request: + format, reserved list, already-approved, and existing DNS record. + Returns {available: bool, reason: 'invalid'|'reserved'|'taken'|None}. + """ + u = (username or "").strip().lower() + if not USERNAME_PATTERN.match(u): + return {"available": False, "reason": "invalid"} + if u in RESERVED_USERNAMES: + return {"available": False, "reason": "reserved"} + if await is_username_taken(u): + return {"available": False, "reason": "taken"} + try: + _cf = await get_cloudflare_config() + if _cf and _cf.domain and _bip353_exists_in_dns(u, _cf.domain): + return {"available": False, "reason": "taken"} + except Exception: + # A DNS/config hiccup shouldn't block the check — the authoritative + # validation still runs on submit in api_create_bip353_request. + pass + return {"available": True, "reason": None} + + +# Transactions +@silnt_api_router.post("/api/v1/tx/build", dependencies=[Depends(require_trusted_device_admin)]) +async def api_build_transaction( + data: BuildTxRequest, key_info: WalletTypeInfo = Depends(require_trusted_device_admin) +): + try: + wallet = await get_silnt_wallet(data.wallet_id) + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) + if wallet.user != key_info.wallet.user: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Access denied." + ) + + if not data.spend_key: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="spend_key is required." + ) + if not data.scan_secret: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="scan_secret is required (used to derive change address).", + ) + # ── Validate UTXOs against the DB: refuse frozen, refuse non-owned ── + if not data.utxos: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="No UTXOs provided for the transaction.", + ) + + def _txid_of(u): + return u["txid"] if isinstance(u, dict) else u.txid + + def _vout_of(u): + if isinstance(u, dict): + return int(u.get("vout", 0)) + return int(getattr(u, "vout", 0) or 0) + + passed_txids_vouts = [(_txid_of(u), _vout_of(u)) for u in data.utxos] + eligible_rows = await get_eligible_utxos( + wallet_id=data.wallet_id, + txid_vout_pairs=passed_txids_vouts, + ) + eligible_set = {(r["txid"], r["vout"]) for r in eligible_rows} + + # Anything passed but not in eligible_set is either frozen, not unspent, + # or doesn't belong to this wallet + rejected = [ + (_txid_of(u), _vout_of(u)) for u in data.utxos + if (_txid_of(u), _vout_of(u)) not in eligible_set + ] + if rejected: + rejected_str = ", ".join(f"{t[:12]}…:{v}" for t, v in rejected) + scanning = bool(get_scan_progress(data.wallet_id).get("active")) + if scanning: + detail = ( + f"Cannot spend these UTXOs ({rejected_str}) because a scan is " + f"in progress and their state just changed. Refresh your UTXOs " + f"and reselect, then try again." + ) + else: + detail = ( + f"Cannot spend these UTXOs ({rejected_str}). " + f"They are either frozen, already spent, or don't belong " + f"to this wallet. Unfreeze them or remove from selection." + ) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=detail) + _orig_recipient = data.recipient.strip() + if "@" in data.recipient: + user, domain = data.recipient.strip().split("@") + if user and domain: + resolved = bip353_resolve(data.recipient) + result = resolved["result"].replace("bitcoin:?sp=", "") + if not result.startswith("sp1") and not result.startswith("tsp1"): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Address must resolve to Silent Payment address (sp1).", + ) + # Tampering guard: if this BitMail is one WE issued on OUR + # configured domain, the DNS TXT must still resolve to the SP + # address we recorded. A mismatch means the record was altered to + # redirect funds — block the send, alert the admin, and ntfy. + try: + cf = await get_cloudflare_config() + our_domain = (getattr(cf, "domain", "") or "").strip().lower() + except Exception: + our_domain = "" + if our_domain and domain.strip().lower() == our_domain: + expected = await get_issued_bitmail_sp_address(user.strip()) + if expected and expected.strip().lower() != result.strip().lower(): + detail = ( + f"BitMail {user}@{domain} resolved to {result} but siLNt " + f"issued it for {expected}. The DNS record may have been " + f"tampered with to redirect funds. Send blocked." + ) + try: + await create_admin_alert( + kind="bitmail_tamper", + severity="critical", + title=f"BitMail tampering: {user}@{domain}", + detail=detail, + meta=json.dumps({ + "bitmail": f"{user}@{domain}", + "resolved_sp": result, + "expected_sp": expected, + }), + ) + except Exception as e: + logger.error(f"could not record bitmail-tamper alert: {e}") + try: + await send_ntfy_notification( + title="⚠ BitMail tampering detected", + message=detail, + tags=["rotating_light"], + priority="urgent", + ) + except Exception as e: + logger.warning(f"ntfy (bitmail tamper) failed: {e}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=( + "This BitMail resolves to an address that does not match " + "what was registered. The send has been blocked and an " + "administrator has been alerted. Do not retry — verify the " + "recipient address out of band." + ), + ) + data.recipient = result + + result = build_transaction( + spend_key_hex=data.spend_key, + scan_secret_hex = data.scan_secret, + recipient=data.recipient, + amount=data.amount, + fee_rate=data.fee_rate, + utxos=data.utxos, + network=wallet.network, + ) + try: + await touch_sp_contact(key_info.wallet.user, _orig_recipient, wallet.network) + except Exception: + pass + return result + except HTTPException: + raise + except Exception as exc: + logger.error(f"Transaction build failed: {exc}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Failed to build transaction: {str(exc)}", + ) from exc + + +@silnt_api_router.post( + "/api/v1/tx/broadcast", dependencies=[Depends(require_trusted_device_admin)] +) +async def api_broadcast_transaction(data: BroadcastTxRequest): + try: + _bwallet = await get_silnt_wallet(data.wallet_id) + if not _bwallet: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found.") + blindbit = await get_backend_config(_bwallet.network) + base = (blindbit.mempool_url or "https://mempool.space").rstrip("/") + mempool_url = f"{base}/api/tx" + + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + mempool_url, content=data.tx_hex, headers={"Content-Type": "text/plain"} + ) + if resp.status_code != 200: + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, + detail=f"Broadcast failed: {resp.text}", + ) + + txid = resp.text.strip() + + # ── Mark the spent inputs immediately so Activity shows the Sent tx + # without waiting for a rescan. Use the exact outpoints the client + # sent; this is keyed on (txid, vout), so it never over-marks. + if data.wallet_id and data.spent_outpoints: + # data.spent_outpoints is a list of {txid, vout} models/dicts. + input_outpoints = [ + (op.txid, op.vout) if hasattr(op, "txid") else (op["txid"], op["vout"]) + for op in data.spent_outpoints + ] + marked = await mark_utxos_spent_by_tx( + wallet_id=data.wallet_id, + input_outpoints=input_outpoints, + spending_txid=txid, + ) + logger.info( + f"Broadcast {txid}: marked {marked} input UTXO(s) unconfirmed_spent" + ) + else: + logger.warning( + f"Broadcast {txid}: no spent_outpoints supplied; the Sent tx " + f"will only appear in Activity after the next rescan" + ) + + return {"txid": txid} + + except HTTPException: + raise + except httpx.ConnectError: + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, + detail=f"Could not connect to {base}", + ) + except httpx.TimeoutException: + raise HTTPException( + status_code=HTTPStatus.GATEWAY_TIMEOUT, detail="mempool.space timed out" + ) + + +@silnt_api_router.post("/api/v1/wallet/{wallet_id}/recover-keys") +async def api_recover_wallet_keys( + wallet_id: str, + data: RecoverKeysRequest, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + """ + Re-derive scan_secret and spend_key from mnemonic for an existing wallet. + Verifies the derived sp_address matches the stored one — protects against + re-importing the wrong mnemonic. + Keys are NEVER stored on the server, only returned transiently. + """ + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist.") + if wallet.user != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied.") + + passphrase = (data.passphrase or "").strip() + try: + mnemonic_plain = decrypt_mnemonic(data.mnemonic, str(data.last_height)) + sp_address, scan_secret_hex, spend_key_hex = await generate_silent_wallet_address( + mnemonic_plain, + passphrase=passphrase, + network=wallet.network, + ) + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Could not derive keys from mnemonic: {str(e)}", + ) + + # Verify derived address matches the stored one — wrong mnemonic = rejection + if sp_address.lower() != wallet.sp_address.lower(): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=( + "The mnemonic you entered does not match this wallet. " + "If this wallet was created with a passphrase, make sure you enter " + "the exact same passphrase." + ), + ) + + # Return keys transiently — they are not stored anywhere on the server + return { + "wallet_id": wallet_id, + "scan_secret": scan_secret_hex, + "spend_key": spend_key_hex, + } + +@silnt_api_router.get("/api/v1/config") +async def api_get_config( + network: Optional[str] = Query(None), + key_info: WalletTypeInfo = Depends(require_trusted_device), +) -> dict: + # No silent signet fallback: require the client's network explicitly so a + # mainnet build can't be served signet's min_scan_height / dust threshold. + if not network: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="A `network` query parameter is required (e.g. ?network=mainnet).", + ) + blindbit = await get_backend_config(network) + config = Config() + # Per-network count so the UI's "wallets used" matches the per-network create + # limit enforced in api_wallet_create. + current = await count_silnt_wallets(key_info.wallet.user, network) + return { + "mempool_endpoint": blindbit.mempool_url or "https://mempool.space", + "sats_denominated": config.sats_denominated, + "network": config.network, + "min_scan_height": blindbit.min_scan_height or 0, + "wallet_count": current, + "dust_threshold_sats": blindbit.dust_threshold_sats or 5000 + } + +@silnt_api_router.get( + "/api/v1/wallet/{wallet_id}/scan/progress", + dependencies=[Depends(require_trusted_device)], +) +async def api_scan_progress(wallet_id: str): + return get_scan_progress(wallet_id) + + +# ── GET Cloudflare config (admin only) ─────────────────────────────────────── +@silnt_api_router.get("/api/v1/cloudflare/config") +async def api_get_cloudflare_config( + key_info: WalletTypeInfo = Depends(require_trusted_device_admin), +) -> CloudflareConfig: + cf = await get_cloudflare_config() + # Backfill domain if credentials are present but domain is missing + # (e.g. config saved before the domain field existed) + if cf.api_token and cf.zone_id and not cf.domain: + try: + cf.domain = await get_zone_domain(cf.api_token, cf.zone_id) + await update_cloudflare_config(cf) + except Exception: + pass + return cf + + +# ── PUT Cloudflare config (admin only) ─────────────────────────────────────── +@silnt_api_router.put("/api/v1/cloudflare/config") +async def api_update_cloudflare_config( + data: CloudflareConfig, + key_info: WalletTypeInfo = Depends(require_trusted_device_admin), +) -> CloudflareConfig: + require_admin(key_info) + # Verify token + zone by fetching domain — also auto-populates data.domain + if data.api_token and data.zone_id: + try: + data.domain = await get_zone_domain(data.api_token, data.zone_id) + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Could not verify Cloudflare zone: {str(e)}", + ) + else: + data.domain = "" + return await update_cloudflare_config(data) + + +# ── POST setup BIP-353 for a wallet via Cloudflare ─────────────────────────── +@silnt_api_router.post("/api/v1/wallet/{wallet_id}/bip353/setup") +async def api_setup_bip353( + wallet_id: str, + data: SetupBip353Request, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) + if wallet.user != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied.") + + # Reject if wallet already has BIP-353 — users must remove first, then create + if wallet.hr_address: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Wallet already has BIP-353 address: {wallet.hr_address}. " + "Remove it first before creating a new one.", + ) + # Validate username format + if not re.match(r"^[a-zA-Z0-9._-]+$", data.username): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Username must contain only letters, numbers, dots, hyphens, underscores.", + ) + + cf = await get_cloudflare_config() + if not cf.api_token or not cf.zone_id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Cloudflare API not configured. Ask your administrator to set it up.", + ) + + try: + result = await create_bip353_record( + api_token=cf.api_token, + zone_id=cf.zone_id, + domain=cf.domain, + username=data.username.lower(), + sp_address=wallet.sp_address, + ttl=data.ttl, + ) + except CloudflareError as e: + raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + + # Update wallet hr_address + await update_hr_address(wallet_id, result["hr_address"]) + + return { + "hr_address": result["hr_address"], + "record_name": result["record_name"], + "action": result["action"], + "sp_address": wallet.sp_address, + } + + +# ── DELETE BIP-353 record for a wallet ─────────────────────────────────────── +@silnt_api_router.delete("/api/v1/wallet/{wallet_id}/bip353") +async def api_delete_bip353( + wallet_id: str, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) + if wallet.user != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied.") + if not wallet.hr_address: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="No BIP-353 address set." + ) + + cf = await get_cloudflare_config() + if not cf.api_token or not cf.zone_id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Cloudflare API not configured." + ) + + try: + username = wallet.hr_address.split("@")[0] + deleted = await delete_bip353_record(cf.api_token, cf.zone_id, cf.domain, username) + except CloudflareError as e: + raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=str(e)) + + # Clear hr_address from wallet + await update_hr_address(wallet_id, "") + + return {"deleted": deleted, "was": wallet.hr_address} + +@silnt_api_router.post("/api/v1/auth/register-start") +async def api_register_start(data: RegistrationRequest, request: Request) -> dict: + return await start_registration(data, request) + +@silnt_api_router.post("/api/v1/auth/register-verify") +async def api_register_verify(data: VerifyRegistrationRequest) -> dict: + return await complete_registration(data.token) + +@silnt_api_router.post("/api/v1/auth/forgot-password") +async def api_forgot_password(data: ForgotPasswordRequest, request: Request) -> dict: + """ + User requests a password reset link by email. + The link is emailed to them and contains a signed reset_key that can be + submitted to LNbits's PUT /api/v1/auth/reset endpoint. + """ + if not data.email or "@" not in data.email: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Valid email address required.", + ) + return await request_password_reset(data.email.strip().lower(), request) + +# UTXO Labels +@silnt_api_router.put("/api/v1/utxos/{txid}/label") +async def api_update_utxo_label( + txid: str, + data: UpdateUtxoLabel, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + # Verify the caller owns the target wallet + wallet = await get_silnt_wallet(data.wallet_id) + if not wallet or wallet.user != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied.") + + # Update only rows in that wallet — never globally by txid + updated = await update_utxo_label_by_txid(txid, data.label, wallet_id=data.wallet_id) + if updated == 0: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="UTXO not found in this wallet.") + + return {"txid": txid, "label": data.label, "updated": updated} + +@silnt_api_router.put("/api/v1/wallet/{wallet_id}/addresses/{addr_id}/label") +async def api_update_address_label( + wallet_id: str, + addr_id: str, + data: UpdateAddressLabelRequest, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist.") + if wallet.user != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied.") + + # Lookup the address to verify it belongs to this wallet AND grab its old label + addr = await get_wallet_address(addr_id) + if not addr: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Labeled address not found.") + if addr.wallet_id != wallet_id: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Address does not belong to this wallet.") + + new_label = (data.label or "").strip() or None + + await update_address_label(addr_id, new_label) + + return { + "id": addr_id, + "label": new_label, + "label_index": addr.label_index, + "sp_address": addr.sp_address, + } + +@silnt_api_router.put("/api/v1/utxos/{txid}/{vout}/frozen") +async def api_set_utxo_frozen( + txid: str, + vout: int, + data: UpdateUtxoFrozenRequest, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + utxos = await get_utxos_by_txid(txid) + if not utxos: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="UTXO not found.") + owned_wallet_ids = {w.id for w in await get_silnt_wallets(key_info.wallet.user)} + matching = next( + (u for u in utxos if u.vout == vout and u.wallet_id in owned_wallet_ids), + None, + ) + if not matching: + if any(u.vout == vout for u in utxos): + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied.") + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="UTXO not found at that vout.") + + if data.frozen: + await set_utxo_freeze_manual(txid, vout) + else: + await clear_utxo_freeze_manual(txid, vout) + + return {"txid": txid, "vout": vout, "frozen": data.frozen} + +@silnt_api_router.get("/api/v1/wallet/{wallet_id}/transactions") +async def api_list_wallet_transactions( + wallet_id: str, + limit: int = 50, + offset: int = 0, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist.") + if wallet.user != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied.") + + txs = await list_wallet_transactions(wallet_id, limit=limit, offset=offset) + return {"transactions": txs} + + +@silnt_api_router.get("/api/v1/wallet/{wallet_id}/transactions/{txid}") +async def api_get_wallet_transaction( + wallet_id: str, + txid: str, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist.") + if wallet.user != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied.") + + return await get_wallet_transaction_detail(wallet_id, txid, wallet.network) + +@silnt_api_router.post("/api/v1/auth/device-check") +async def api_device_check( + request: Request, + response: Response, + key_info: WalletTypeInfo = Depends(require_invoice_key), +): + user_id = key_info.wallet.user + # cookie = request.cookies.get(cookie_name_for_user(user_id)) + cookie = read_device_id(request, user_id) + ua = request.headers.get("user-agent", "")[:512] + # Brave (and some others) masquerade as Chrome in the UA. The client sends a + # reliable brand hint when it can detect one; prefix it so the UA-based label + # reflects the real browser. Whitelist known values to avoid header injection. + brand = (request.headers.get("x-client-brand", "") or "").strip()[:16] + if brand in ("Brave",) and "Brave" not in ua: + ua = f"{brand} {ua}"[:512] + ip = get_client_ip(request) + + if cookie: + existing = await get_trusted_device(user_id, cookie) + if existing: + await touch_trusted_device(user_id, cookie) + set_device_cookie(response, user_id, cookie) # refresh expiry + return DeviceCheckResponse( + status = "trusted", + device_count = await count_trusted_devices(user_id), + cap = MAX_TRUSTED_DEVICES_PER_USER, + ) + return DeviceCheckResponse( + status = "untrusted", + device_count = await count_trusted_devices(user_id), + cap = MAX_TRUSTED_DEVICES_PER_USER, + ) + +@silnt_api_router.post("/api/v1/auth/device-request-confirm") +async def api_device_request_confirm( + request: Request, + response: Response, + key_info: WalletTypeInfo = Depends(require_invoice_key), +): + """Explicitly send a new-device confirmation email. Called ONLY when the user + chooses to confirm this device (the 'Send confirmation email' action on the + pending-device screen) — never automatically. This is the email-sending half + that used to live inside device-check.""" + user_id = key_info.wallet.user + # cookie = request.cookies.get(cookie_name_for_user(user_id)) + cookie = read_device_id(request, user_id) + ua = request.headers.get("user-agent", "")[:512] + brand = (request.headers.get("x-client-brand", "") or "").strip()[:16] + if brand in ("Brave",) and "Brave" not in ua: + ua = f"{brand} {ua}"[:512] + ip = get_client_ip(request) + + # Already trusted on this browser? Nothing to send. + if cookie: + existing = await get_trusted_device(user_id, cookie) + if existing: + await touch_trusted_device(user_id, cookie) + set_device_cookie(response, user_id, cookie) + return {"status": "already-trusted"} + + new_device_id = secrets.token_urlsafe(24) + + current_count = await count_trusted_devices(user_id) + if current_count >= MAX_TRUSTED_DEVICES_PER_USER: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=( + f"This account already has {MAX_TRUSTED_DEVICES_PER_USER} " + f"trusted devices (the maximum). Sign in from a trusted device " + f"and revoke one in Settings → Devices before continuing." + ), + ) + + account = await get_account(user_id) + if not account or not account.email: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="This account has no email address. Cannot send confirmation.", + ) + + code = f"{secrets.randbelow(1000000):06d}" + await create_device_code(user_id, code, new_device_id, ua, ip, ttl_secs=600) + subject = "Your device confirmation code" + body = ( + f"Hi {account.username or 'there'},\n\n" + f"Your device confirmation code is:\n\n {code}\n\n" + f"Enter it in the app on the device you're signing in from. " + f"This code expires in 10 minutes.\n\n" + f"A sign-in was attempted from:\n" + f" Browser: {ua or 'unknown'}\n IP: {ip or 'unknown'}\n" + f" Time: {time.strftime('%Y-%m-%d %H:%M UTC', time.gmtime())}\n\n" + f"If this WASN'T you, someone may have your password. Change your " + f"password and contact your administrator. (The IP shown may be a " + f"CDN/proxy IP and may not reflect the actual location.)" + ) + res = await send_email_notification(to_emails=[account.email], message=body, subject=subject) + if res.get("status") != "ok": + logger.warning(f"Device code email failed: {res}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Could not send confirmation code. Please contact your administrator.") + # No valid trusted-device cookie. This endpoint is CHECK-ONLY: it never sends + # a confirmation email or mutates state. That avoids auto-emailing on every + # untrusted check (e.g. right after a user revokes their own device, or when + # a privacy browser / VPN drops the cookie). The confirmation email is sent + # only when the user explicitly asks, via /auth/device-request-confirm. + try: + existing_count = await count_trusted_devices(user_id) + if existing_count > 0: + sig = hashlib.sha256(f"{ua}|{ip}".encode()).hexdigest()[:32] + if await should_send_login_alert(user_id, sig): + account = await get_account(user_id) + if account and account.email: + subject = "New device sign-in to your wallet" + body = ( + f"Hi {account.username or 'there'},\n\n" + f"Your account was just signed in to from a device that isn't " + f"one of your confirmed devices:\n\n" + f" Browser: {ua or 'unknown'}\n" + f" IP: {ip or 'unknown'}\n" + f" Time: {time.strftime('%Y-%m-%d %H:%M UTC', time.gmtime())}\n\n" + f"If this was you, just confirm the device from the app to start " + f"using it — nothing else to do.\n\n" + f"If this WASN'T you, someone may have your password. Your wallet " + f"is still safe (they can't use it without also confirming the " + f"device by email), but you should change your password and " + f"contact your administrator.\n\n" + f"(Note: the IP shown may be a CDN/proxy IP and may not " + f"reflect the actual location.)" + ) + res = await send_email_notification( + to_emails=[account.email], message=body, subject=subject + ) + if res.get("status") != "ok": + logger.warning(f"Login-alert email failed for {user_id}: {res}") + except Exception as e: + logger.warning(f"Login-alert send skipped: {e}") + return {"status": "sent"} + +@silnt_api_router.post("/api/v1/auth/device-verify-code") +async def api_device_verify_code(request: Request, response: Response, + key_info: WalletTypeInfo = Depends(require_invoice_key)): + user_id = key_info.wallet.user + try: + payload = await request.json() + except Exception: + payload = {} + code = str((payload or {}).get("code", "")).strip() + if not code.isdigit() or len(code) != 6: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Enter the 6-digit code.") + pending = await verify_device_code(user_id, code) + if not pending: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail="That code is incorrect or expired. Request a new one if needed.") + current_count = await count_trusted_devices(user_id) + if current_count >= MAX_TRUSTED_DEVICES_PER_USER: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail=f"This account already has {MAX_TRUSTED_DEVICES_PER_USER} trusted devices. Revoke one before adding another.") + device_id = pending["device_id"] + await add_trusted_device(user_id=user_id, device_id=device_id, + user_agent=pending.get("user_agent") or "", ip=pending.get("ip") or "") + set_device_cookie(response, user_id, device_id) + return DeviceConfirmResponse(confirmed=True, device_count=current_count + 1, cap=MAX_TRUSTED_DEVICES_PER_USER, device_id = device_id) + + +@silnt_api_router.get("/api/v1/devices") +async def api_list_devices( + request: Request, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + user_id = key_info.wallet.user + devices = await list_trusted_devices(user_id) + # current = request.cookies.get(cookie_name_for_user(user_id)) + current = read_device_id(request, user_id) + return DeviceListResponse( + devices = devices, + current_device = current, + cap = MAX_TRUSTED_DEVICES_PER_USER, + ) + + +@silnt_api_router.delete("/api/v1/devices/{device_row_id}") +async def api_revoke_device( + device_row_id: str, + request: Request, + response: Response, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + user_id = key_info.wallet.user + devices = await list_trusted_devices(user_id) + target = next((d for d in devices if d.id == device_row_id), None) + if not target: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Device not found.") + + deleted = await revoke_trusted_device(user_id, device_row_id) + + # current = request.cookies.get(cookie_name_for_user(user_id)) + current = read_device_id(request, user_id) + was_current = (target.device_id == current) + if was_current: + clear_device_cookie(response, user_id) + + return {"deleted": bool(deleted), "device_row_id": device_row_id, "was_current": was_current} + + +@silnt_api_router.post("/api/v1/devices/sign-out-others") +async def api_sign_out_others( + request: Request, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + user_id = key_info.wallet.user + # current = request.cookies.get(cookie_name_for_user(user_id)) + current = read_device_id(request, user_id) + if not current: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="No active device.") + removed = await revoke_all_other_devices(user_id, current) + return {"removed_count": removed} + +@silnt_api_router.get("/api/v1/auth/is-admin") +async def api_is_admin(key_info: WalletTypeInfo = Depends(require_invoice_key)): + return {"is_admin": is_lnbits_admin(key_info.wallet.user)} + +@silnt_api_router.get("/api/v1/auth/me") +async def api_whoami( + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + """Return basic info about the current authenticated user.""" + account = await get_account(key_info.wallet.user) + return WhoamiResponse( + user_id = key_info.wallet.user, + username = account.username if account else None, + email = account.email if account else None, + is_admin = is_lnbits_admin(key_info.wallet.user), + ) + +@silnt_api_router.get("/api/v1/user/prefs") +async def api_get_user_prefs( + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + """ + Get the current user's prefs + the admin default (so the UI can show + 'You have no override set — using admin default of N sats'). + """ + user_id = key_info.wallet.user + prefs = await get_user_prefs(user_id) + backend = await get_backend_config(DEFAULT_CONFIG_NETWORK) + admin_default = int(backend.dust_threshold_sats or 5000) + return { + "user_id": user_id, + "dust_threshold_sats": prefs.dust_threshold_sats if prefs else None, + "admin_default_dust": admin_default, + "effective_dust_threshold": await get_effective_dust_threshold(user_id), + } + + +@silnt_api_router.put("/api/v1/user/prefs") +async def api_update_user_prefs( + data: UpdateUserPrefsRequest, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + """ + Update the current user's prefs. Passing dust_threshold_sats=None (or 0) + clears the override — user falls back to admin default. + """ + user_id = key_info.wallet.user + + dts = data.dust_threshold_sats + if dts is not None: + try: + dts = int(dts) + except (TypeError, ValueError): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="dust_threshold_sats must be an integer or null.", + ) + if dts <= 0: + # Treat 0 / negative as "revert to admin default" + dts = None + elif dts > 10_000: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="dust_threshold_sats too large (max 10000).", + ) + + await upsert_user_prefs(user_id, dts) + # Re-evaluate existing UTXOs against the new threshold so the change applies + # to what's already in the wallet — not just to UTXOs found on the next scan. + reevaluated = 0 + try: + for w in await get_silnt_wallets(user_id): + try: + await evaluate_dust_for_wallet(w.id) + reevaluated = 1 + except Exception as e: + logger.warning(f"dust re-eval failed for wallet {w.id}: {e}") + except Exception as e: + logger.warning(f"dust re-eval skipped after prefs update: {e}") + backend = await get_backend_config(DEFAULT_CONFIG_NETWORK) + return { + "user_id": user_id, + "dust_threshold_sats": dts, + "admin_default_dust": int(backend.dust_threshold_sats or 5000), + "effective_dust_threshold": await get_effective_dust_threshold(user_id), + "wallets_reevaluated": reevaluated + } + +@silnt_api_router.post("/api/v1/bip353/request") +async def api_create_bip353_request( + data: CreateBip353Request, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + user_id = key_info.wallet.user + username = (data.requested_username or "").strip().lower() + + if not USERNAME_PATTERN.match(username): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Username must be 3–20 chars, lowercase letters, digits, dash, or underscore.", + ) + if username in RESERVED_USERNAMES: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="This username is reserved. Choose another.", + ) + if await is_username_taken(username): + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail="That BitMail username is already taken. Choose another.", + ) + _cf = await get_cloudflare_config() + if _cf and _cf.domain and _bip353_exists_in_dns(username, _cf.domain): + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail="That BitMail username is already taken. Choose another.", + ) + # Verify wallet ownership FIRST so all checks below are wallet-scoped + wallet = await get_silnt_wallet(data.wallet_id) + if not wallet or wallet.user != user_id: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Wallet not found.") + if not wallet.sp_address: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Wallet has no SP address yet — scan first.", + ) + + # Resolve the TARGET address: None = wallet base address; else a labeled address row. + address_id = data.address_id + if address_id is None: + target_sp_address = wallet.sp_address + current_hr = (wallet.hr_address or "").strip() + else: + addr = await get_wallet_address(address_id) + if not addr or addr.wallet_id != data.wallet_id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Labeled address not found on this wallet.", + ) + target_sp_address = addr.sp_address + current_hr = (addr.hr_address or "").strip() + + # This specific address already HAS a BitMail right now. + if current_hr: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"This address already has the BitMail {current_hr}. " + f"Remove it first — note a removed BitMail cannot be re-added to the same address.", + ) + + # Assign-once: this address was granted a BitMail before (even if since removed). + if await address_has_approved_bitmail(data.wallet_id, address_id) or await sp_address_has_approved_bitmail(target_sp_address): + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="This address previously had a BitMail and cannot be assigned another. " + "BitMail assignment is permanent per address.", + ) + + # Wallet-wide cap across base + labeled addresses (base + 2 labeled = 3). + used = await count_approved_bip353_for_wallet(data.wallet_id) + if used >= BITMAIL_MAX_ACQUISITIONS: + raise HTTPException( + HTTPStatus.FORBIDDEN, + f"This wallet has reached the limit of {BITMAIL_MAX_ACQUISITIONS} " + f"BitMail addresses and cannot request another.", + ) + req = await create_bip353_request( + user_id = user_id, + wallet_id = data.wallet_id, + sp_address = target_sp_address, + requested_username = username, + message = data.message, + address_id = address_id, + ) + # Notify admins there's a new BitMail request awaiting review (best-effort). + try: + acct = await get_account(user_id) + requester = (getattr(acct, "username", None) or "").strip() if acct else "" + requester_label = requester or user_id + await send_ntfy_notification( + title="New BitMail request", + message=( + f"User '{requester_label}' requested the BitMail username " + f"'{username}' and is awaiting approval." + ), + tags=["email"], + ) + except Exception as e: + logger.warning(f"ntfy notify (new bitmail request) failed: {e}") + return req + +@silnt_api_router.get("/api/v1/bip353/requests") +async def api_list_user_requests( + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + requests = await list_user_bip353_requests(key_info.wallet.user) + return {"requests": requests} + + +@silnt_api_router.delete("/api/v1/bip353/requests/{req_id}") +async def api_cancel_user_request( + req_id: str, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + cancelled = await cancel_user_request(req_id, key_info.wallet.user) + if not cancelled: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="No pending request found with that ID.", + ) + return {"cancelled": True} + +def _bip353_exists_in_dns(username: str, domain: str) -> bool: + """True if {username}@{domain} already resolves as a BIP-353 DNS record.""" + if not domain: + return False + try: + res = bip353_resolve(f"{username}@{domain}") + return bool(res and res.get("result")) + except HTTPException as e: + if e.status_code == HTTPStatus.NOT_FOUND: + return False + return True + except Exception: + return False + +@silnt_api_router.get("/api/v1/bip353/admin/requests") +async def api_admin_list_requests( + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + require_admin(key_info) + pending = await list_pending_bip353_requests() + # Enrich each with requester info for easier display + enriched = [] + for r in pending: + account = await get_account(r.user_id) + enriched.append({ + **r.dict(), + "requester_username": account.username if account else None, + "requester_email": account.email if account else None, + }) + return {"requests": enriched} + + +@silnt_api_router.post("/api/v1/bip353/admin/requests/{req_id}/approve") +async def api_admin_approve_request( + req_id: str, + data: ApproveBip353Request, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + require_admin(key_info) + req = await get_bip353_request(req_id) + if not req: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Request not found.") + if req.status != "pending": + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Request is already {req.status}.", + ) + + final_username = (data.final_username or req.requested_username).strip().lower() + if not USERNAME_PATTERN.match(final_username): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Final username is invalid.", + ) + if await sp_address_has_approved_bitmail(req.sp_address): + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="This address previously had a BitMail and cannot be assigned " + "another. BitMail assignment is permanent per address.", + ) + _cf_check = await get_cloudflare_config() + if await is_username_approved_elsewhere(final_username, req_id) or ( + _cf_check and _cf_check.domain + and _bip353_exists_in_dns(final_username, _cf_check.domain) + ): + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail="That username is already taken (already published).", + ) + + # Verify CF integration is configured + cf_config = await get_cloudflare_config() + if not cf_config or not cf_config.api_token or not cf_config.zone_id or not cf_config.domain: + raise HTTPException( + status_code=HTTPStatus.PRECONDITION_FAILED, + detail="Cloudflare integration is not configured. " + "Set it up in Settings → Cloudflare first.", + ) + + # 1. Write the Cloudflare TXT record. + # ★ Confirm the function name + arg order matches your bip353_cloudflare.py ★ + try: + await create_bip353_record( + api_token = cf_config.api_token, + zone_id = cf_config.zone_id, + domain = cf_config.domain, + username = final_username, + sp_address = req.sp_address, + ) + except Exception as e: + logger.error(f"Cloudflare BIP-353 create failed for {final_username}: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Cloudflare record creation failed: {str(e)}", + ) + + full_address = f"{final_username}@{cf_config.domain}" + try: + if req.address_id is None: + await update_hr_address(req.wallet_id, full_address) # base address + else: + await update_label_hr_address(req.address_id, full_address) # labeled address + except Exception as e: + logger.error(f"hr_address update failed after CF create: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cloudflare record was created but the wallet update failed. " + "Contact your administrator.", + ) + + # 3. Mark the request approved + await update_bip353_request_status( + req_id = req_id, + status = "approved", + processed_by = key_info.wallet.user, + final_username = final_username, + ) + + return { + "approved": True, + "final_username": final_username, + "hr_address": full_address, + } + +@silnt_api_router.post("/api/v1/bip353/admin/requests/{req_id}/reject") +async def api_admin_reject_request( + req_id: str, + data: RejectBip353Request, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + require_admin(key_info) + req = await get_bip353_request(req_id) + if not req: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Request not found.") + if req.status != "pending": + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Request is already {req.status}.", + ) + + await update_bip353_request_status( + req_id = req_id, + status = "rejected", + processed_by = key_info.wallet.user, + reject_reason = data.reason.strip()[:500], + ) + return {"rejected": True} + +@silnt_api_router.get("/api/v1/bitmail/domain") +async def api_get_bitmail_domain( + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + """ + BitMail domain for display. Read from the existing cloudflare_config.domain. + No secrets (api_token/zone_id) are returned. + """ + cf = await get_cloudflare_config() # your existing getter + domain = "" + if cf: + # cf may be a model or a dict — handle both + domain = getattr(cf, "domain", None) or (cf.get("domain") if isinstance(cf, dict) else "") or "" + return {"domain": domain} + +@silnt_api_router.post( + "/api/v1/utxos/restore", dependencies=[Depends(require_trusted_device_admin)] +) +async def api_restore_utxo(data: RestoreUtxoRequest): + wallet = await get_silnt_wallet(data.wallet_id) + if not wallet: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found.") + + # Find the UTXO and confirm it's unconfirmed_spent + utxo = await get_utxo(data.wallet_id, data.txid, data.vout) + if not utxo: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="UTXO not found.") + if utxo["utxo_state"] != "unconfirmed_spent": + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"UTXO is '{utxo['utxo_state']}', not 'unconfirmed_spent' — nothing to restore.", + ) + + # Verify the spending tx is actually gone (don't restore a still-pending or + # confirmed spend — that would corrupt state). + blindbit = await get_backend_config(wallet.network) + status = await get_tx_status( + blindbit.mempool_url or "https://mempool.space", utxo["spent_in_txid"] + ) + if status is not None and not status.get("unknown"): + if status.get("confirmed"): + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail="The spending transaction has confirmed — this UTXO is genuinely spent.", + ) + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail="The spending transaction is still pending in the mempool. " + "Wait until it drops before restoring.", + ) + if status is not None and status.get("unknown"): + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, + detail="Couldn't verify the transaction status right now. Try again shortly.", + ) + + # status is None → tx is gone → safe to restore + restored = await restore_utxo_to_unspent(data.wallet_id, data.txid, data.vout) + if restored == 0: + # Race: state changed between read and write (e.g. a concurrent scan). + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail="UTXO state changed before it could be restored. Refresh and try again.", + ) + + # Recompute and persist balance from the now-updated UTXO set + balance = await get_wallet_unspent_balance(data.wallet_id) + await update_balance(data.wallet_id, balance) + + return {"restored": True, "txid": data.txid, "vout": data.vout, "balance": balance} + +@silnt_api_router.delete("/api/v1/wallet/{wallet_id}/bip353") +async def api_remove_bip353( + wallet_id: str, + address_id: Optional[str] = Query(None), # None = wallet base address; else a labeled address + key_info: WalletTypeInfo = Depends(require_trusted_device), # any logged-in user +): + # 1. Resolve the wallet and ENFORCE OWNERSHIP — the whole security boundary. + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise HTTPException(HTTPStatus.NOT_FOUND, "Wallet not found.") + if wallet.user != key_info.wallet.user: + raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.") + + # 2. Resolve which address's BitMail we're removing. + label_addr = None + if address_id is None: + hr = (wallet.hr_address or "").strip() + else: + label_addr = await get_wallet_address(address_id) + if not label_addr or label_addr.wallet_id != wallet_id: + raise HTTPException(HTTPStatus.BAD_REQUEST, "Labeled address not found on this wallet.") + hr = (label_addr.hr_address or "").strip() + + if not hr: + return {"removed": False, "reason": "no BitMail address on this address"} + + # 3. Delete the DNS record (Cloudflare). The DNS-write capability is contained + # to the caller's OWN address by the ownership check above. + cf = await get_cloudflare_config() + if cf and getattr(cf, "api_token", "") and getattr(cf, "zone_id", ""): + if "@" in hr: + uname = hr.split("@", 1)[0] + try: + await delete_bip353_record(cf.api_token, cf.zone_id, cf.domain, uname) + logger.info(f"Removed BitMail DNS for {hr} (owner {key_info.wallet.user})") + except Exception as e: + logger.error(f"BitMail DNS removal failed for {hr}: {e}") + raise HTTPException( + HTTPStatus.BAD_GATEWAY, + "Could not remove the BitMail DNS record. Please try again.", + ) + else: + logger.warning("Cloudflare not configured; clearing hr_address without DNS delete.") + + # 4. Clear hr_address on the right row. The bip353_requests 'approved' row is + # left intact, so address_has_approved_bitmail() keeps the slot burned — + # a removed BitMail cannot be re-added to the same address. + if address_id is None: + await clear_wallet_hr_address(wallet_id) + else: + await clear_label_hr_address(address_id) + return {"removed": True, "hr_address": hr} + +@silnt_api_router.post( + "/api/v1/account/close", dependencies=[Depends(require_trusted_device)] +) +async def api_close_account( + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + user_id = key_info.wallet.user + + # 1. Best-effort: remove any BitMail DNS records this user's wallets own, so + # we don't leave dangling TXT records pointing at deleted wallets. + try: + cf = await get_cloudflare_config() + if cf and getattr(cf, "api_token", "") and getattr(cf, "zone_id", ""): + hr_addresses = await get_user_hr_addresses(user_id) + for hr in hr_addresses: + if "@" in hr: + uname = hr.split("@", 1)[0] + try: + await delete_bip353_record(cf.api_token, cf.zone_id, cf.domain, uname) + logger.info(f"Removed BitMail DNS for {hr} during account close") + except Exception as e: + logger.warning(f"Could not remove BitMail DNS for {hr}: {e}") + except Exception as e: + logger.warning(f"BitMail cleanup skipped during account close: {e}") + + # 2. Delete all siLNt data for this user + deleted_wallet_ids = [] + try: + stats = await delete_all_silnt_data_for_user(user_id) + deleted_wallet_ids = stats.get("wallet_ids", []) + logger.info(f"Deleted siLNt data for user {user_id}: {stats}") + except Exception as e: + logger.error(f"Failed to delete siLNt data for {user_id}: {e}") + raise HTTPException( + HTTPStatus.INTERNAL_SERVER_ERROR, + "Could not remove wallet data. Account not closed — please try again.", + ) + + # 3. Delete the LNbits account itself (removes the user, their wallets, keys) + try: + await delete_account(user_id) # delete_account(account_id) — user_id IS the account id + logger.info(f"LNbits account deleted: {user_id}") + except Exception as e: + logger.error(f"Failed to delete LNbits account {user_id}: {e}") + raise HTTPException( + HTTPStatus.INTERNAL_SERVER_ERROR, + "Wallet data was removed but the account could not be fully deleted. " + "Please contact the administrator.", + ) + + return {"closed": True, "wallet_ids": deleted_wallet_ids} + +@silnt_api_router.get( + "/api/v1/fees/recommended", dependencies=[Depends(require_trusted_device)] +) +async def api_recommended_fees(network: str = Query(DEFAULT_CONFIG_NETWORK)): + """Recommended fee tiers (sat/vB) for the configured network.""" + return await get_recommended_fees(network) + +@silnt_api_router.get( + "/api/v1/rate/usd", dependencies=[Depends(require_trusted_device)] +) +async def api_btc_usd_rate(): + """BTC/USD rate for the unit toggle. {'rate': } (0 if unavailable).""" + return {"rate": await get_btc_usd_rate()} + +@silnt_api_router.get("/api/v1/tx/{txid}/confirmation") +async def api_tx_confirmation( + txid: str, + wallet_id: str, + key_info: WalletTypeInfo = Depends(require_admin_key), +): + """ + Check whether an outgoing send tx has confirmed. If confirmed, transition the + wallet's spent inputs from 'unconfirmed_spent' to 'spent' and refresh balance. + Lightweight: one mempool/esplora lookup for this txid — NOT a scan. + Returns {confirmed: bool, block_height: int|None, balance: int}. + """ + # Ownership: only let a wallet query a tx it actually sent. + wallet = await get_silnt_wallet(wallet_id) + if not wallet: + raise HTTPException(HTTPStatus.NOT_FOUND, "Wallet not found.") + # (Add your usual user/wallet ownership check here, matching other endpoints.) + + cfg = await get_backend_config(wallet.network) + mempool = (cfg.mempool_url or "").rstrip("/") + if not mempool: + raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, "Mempool URL not configured.") + + confirmed = False + block_height = None + async with httpx.AsyncClient(timeout=20) as c: + r = await c.get(f"{mempool}/api/tx/{txid}/status") + if r.status_code == 200: + st = r.json() + confirmed = bool(st.get("confirmed")) + block_height = st.get("block_height") + + if confirmed: + # Finalize: unconfirmed_spent -> spent for this tx's inputs in this wallet. + await mark_utxos_confirmed_spent_by_tx(wallet_id, txid) + # Recompute balance from unspent UTXOs and persist it. + new_balance = await get_wallet_unspent_balance(wallet_id) + await update_balance(wallet_id, new_balance) + else: + new_balance = await get_wallet_unspent_balance(wallet_id) + + return {"confirmed": confirmed, "block_height": block_height, "balance": new_balance} + + +@silnt_api_router.get("/api/v1/admin/blindbit/health") +async def api_blindbit_health( + network: str = Query("signet"), + key_info: WalletTypeInfo = Depends(require_admin_key), +): + """ + Health = BlindBit reachable AND in sync with the chain tip (mempool/esplora). + Comparing heights is robust to bursty block production (mainnet can go hours + with no block); a real stall shows up as BlindBit falling behind the tip. + Returns up/down + whether the heights diverge (+ the heights only when they do). + """ + blindbit = await get_backend_config(network) + bb_url = (blindbit.blindbit_url or "").rstrip("/") + mp_url = (blindbit.mempool_url or "").rstrip("/") + if not bb_url: + return {"ok": False, "in_sync": False, "error": "BlindBit Oracle URL not configured.", + "blindbit_height": None, "tip_height": None, "behind_by": None, "latency_ms": None} + + started = _time.monotonic() + # 1) BlindBit height (the thing we're checking) + try: + async with httpx.AsyncClient(timeout=8.0, verify=False) as c: + r = await c.get(f"{bb_url}/info") + latency_ms = int((_time.monotonic() - started) * 1000) + if r.status_code != 200: + await notify_service_health_change("BlindBit Oracle", False, f"HTTP {r.status_code}") + return {"ok": False, "in_sync": False, "error": f"Oracle returned HTTP {r.status_code}.", + "blindbit_height": None, "tip_height": None, "behind_by": None, "latency_ms": latency_ms} + bb_height = int(r.json().get("height")) + except Exception as exc: + latency_ms = int((_time.monotonic() - started) * 1000) + await notify_service_health_change("BlindBit Oracle", False, str(exc)[:120]) + return {"ok": False, "in_sync": False, "error": str(exc)[:200], + "blindbit_height": None, "tip_height": None, "behind_by": None, "latency_ms": latency_ms} + + # 2) Chain tip from mempool/esplora (the reference). If we can't get it, we + # can still report BlindBit is UP, just can't assess sync. + tip_height = None + if mp_url: + try: + async with httpx.AsyncClient(timeout=8.0, verify=False) as c: + rt = await c.get(f"{mp_url}/api/blocks/tip/height") + if rt.status_code == 200: + tip_height = int(rt.text.strip()) + except Exception: + tip_height = None + + if tip_height is None: + await notify_service_health_change("BlindBit Oracle", True) + # BlindBit is up but we couldn't fetch the tip to compare. + return {"ok": True, "in_sync": None, "error": "Could not fetch chain tip to compare.", + "blindbit_height": bb_height, "tip_height": None, "behind_by": None, "latency_ms": latency_ms} + + behind_by = tip_height - bb_height # positive = BlindBit is behind + in_sync = behind_by <= BLINDBIT_SYNC_TOLERANCE # ahead or within tolerance = healthy + await notify_service_health_change("BlindBit Oracle", True) + return { + "ok": True, + "in_sync": in_sync, + "error": None, + "blindbit_height": bb_height, + "tip_height": tip_height, + "behind_by": behind_by, + "latency_ms": latency_ms, + } + +@silnt_api_router.get("/api/v1/bip353/admin/requests/history") +async def api_admin_request_history( + limit: int = 13, + offset: int = 0, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + require_admin(key_info) + reqs = await list_all_bip353_requests(limit=limit, offset=offset) + enriched = [] + for r in reqs: + account = await get_account(r.user_id) + enriched.append({ + **r.dict(), + "requester_username": account.username if account else None, + "requester_email": account.email if account else None, + }) + return {"requests": enriched} + + +@silnt_api_router.delete("/api/v1/bip353/admin/requests/{req_id}") +async def api_admin_purge_request( + req_id: str, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + require_admin(key_info) + deleted = await delete_bip353_request_if_terminal(req_id) + if not deleted: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Only rejected or cancelled requests can be purged.", + ) + return {"purged": True, "id": req_id} + + +@silnt_api_router.post("/api/v1/bip353/admin/requests/purge-terminal") +async def api_admin_purge_terminal( + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + require_admin(key_info) + count = await delete_terminal_bip353_requests() + return {"purged": count} + +# ── fulcrum config (host/port for SYNC) ─────────────────────────────────────── +# Sync uses Fulcrum; broadcast uses mempool (reused). Pull Fulcrum host/port from +# blindbit config — add fields there, or hardcode per-instance for now. +async def _fulcrum_cfg(network: str): + cfg = await get_backend_config(network) + return ( + getattr(cfg, "fulcrum_host", "127.0.0.1"), + int(getattr(cfg, "fulcrum_port", 50001)), + bool(getattr(cfg, "fulcrum_tls", False)), + network, + ) + + +async def _broadcast_via_mempool(tx_hex: str, network: str) -> str: + """Reuse siLNt's mempool broadcast path (same as /api/v1/tx/broadcast).""" + blindbit = await get_backend_config(network) + base = (blindbit.mempool_url or "https://mempool.space").rstrip("/") + url = f"{base}/api/tx" + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(url, content=tx_hex, headers={"Content-Type": "text/plain"}) + if resp.status_code != 200: + raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, + detail=f"Broadcast failed: {resp.text}") + return resp.text.strip() + +# ── descriptors ─────────────────────────────────────────────────────────────── +@silnt_api_router.post("/api/v1/payjoin/descriptors") +async def api_payjoin_import_descriptor( + data: ImportDescriptorData, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + network = data.network + try: + d = await create_payjoin_descriptor( + user_id=key_info.wallet.user, descriptor=data.descriptor, + network=network, label=data.label, + ) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + return d.dict() + + +@silnt_api_router.get("/api/v1/payjoin/descriptors") +async def api_payjoin_list_descriptors( + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + rows = await list_payjoin_descriptors(key_info.wallet.user) + return [r.dict() for r in rows] + + +@silnt_api_router.delete("/api/v1/payjoin/descriptors/{did}") +async def api_payjoin_delete_descriptor( + did: str, key_info: WalletTypeInfo = Depends(require_trusted_device), +): + d = await get_payjoin_descriptor(did) + if not d or d.user_id != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Not found.") + await delete_payjoin_descriptor(did, key_info.wallet.user) + return {"deleted": True} + + +@silnt_api_router.get("/api/v1/payjoin/descriptors/{did}/utxos") +async def api_payjoin_utxos( + did: str, key_info: WalletTypeInfo = Depends(require_trusted_device), +): + d = await get_payjoin_descriptor(did) + if not d or d.user_id != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Not found.") + host, port, tls, network = await _fulcrum_cfg(d.network) + try: + res = sync_wallet(d.descriptor, network, host, port, use_tls=tls) + except Exception as e: + logger.warning(f"payjoin sync failed: {e}") + raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, + detail=f"Fulcrum sync failed: {e}") + reserved = await get_reserved_outpoints(key_info.wallet.user) + utxos_out = [] + for u in res.utxos: + d2 = dict(u.__dict__) + d2["reserved"] = f"{u.txid}:{u.vout}" in reserved + d2["unconfirmed"] = int(getattr(u, "height", 0) or 0) <= 0 + utxos_out.append(d2) + return { + "confirmed_sats": res.confirmed_sats, + "unconfirmed_sats": res.unconfirmed_sats, + "utxos": utxos_out, + } + + +# ── propose (sender) ────────────────────────────────────────────────────────── +@silnt_api_router.post("/api/v1/payjoin/contacts") +async def api_payjoin_contact_request( + data: CreateContactData, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + uid = key_info.wallet.user + username = (data.username or "").strip().lower() + if not username: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Enter a username.") + + target = await get_account_by_username(username) + target_id = target.id if target else None + + # Clear error if you enter your OWN username (harmless self-oracle — you know + # your own handle). Otherwise stay neutral (no username-existence oracle). + me = await get_account(uid) + if me and me.username and me.username.strip().lower() == username: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail="That's your own username — connect with someone else.") + + if target_id and target_id != uid: + await create_payjoin_contact(uid, target_id) + return {"status": "sent"} + + +@silnt_api_router.get("/api/v1/payjoin/contacts") +async def api_payjoin_contacts( + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + """All of my connections: accepted, incoming pending, outgoing pending. + Usernames + this user's private labels resolved here (never stored together).""" + uid = key_info.wallet.user + res = await list_payjoin_contacts(uid) + labels = await get_payjoin_contact_labels(uid) + cache = {} + async def uname(u): + if u not in cache: + acct = await get_account(u) + cache[u] = (acct.username if acct and acct.username else u) + return cache[u] + for group in res.values(): + for d in group: + d["counterparty_username"] = await uname(d["counterparty_user_id"]) + d["label"] = labels.get(d["id"], "") + return res + + +@silnt_api_router.post("/api/v1/payjoin/contacts/{cid}/approve") +async def api_payjoin_contact_approve( + cid: str, key_info: WalletTypeInfo = Depends(require_trusted_device), +): + """The TARGET of a pending request approves it -> ACCEPTED (mutual).""" + c = await get_payjoin_contact(cid) + if not c or c.target_user_id != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Request not found.") + if c.status != "PENDING": + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Not pending.") + await set_payjoin_contact_status(cid, "ACCEPTED") + return {"status": "ACCEPTED"} + + +@silnt_api_router.post("/api/v1/payjoin/contacts/{cid}/decline") +async def api_payjoin_contact_decline( + cid: str, key_info: WalletTypeInfo = Depends(require_trusted_device), +): + """The TARGET declines a pending request -> status DECLINED (kept so the + requester sees the outcome; they dismiss it to remove).""" + c = await get_payjoin_contact(cid) + if not c or c.target_user_id != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Request not found.") + if c.status != "PENDING": + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Not pending.") + await set_payjoin_contact_status(cid, "DECLINED") + return {"status": "DECLINED"} + + +@silnt_api_router.delete("/api/v1/payjoin/contacts/{cid}") +async def api_payjoin_contact_remove( + cid: str, key_info: WalletTypeInfo = Depends(require_trusted_device), +): + """Either party removes the connection (pending or accepted).""" + c = await get_payjoin_contact(cid) + uid = key_info.wallet.user + if not c or uid not in (c.requester_user_id, c.target_user_id): + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Not found.") + await delete_payjoin_contact(cid) + return {"status": "REMOVED"} + + +@silnt_api_router.post("/api/v1/payjoin/contacts/{cid}/label") +async def api_payjoin_contact_label( + cid: str, data: ContactLabelData, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + """Set/clear this user's PRIVATE label for a connection (only they see it).""" + c = await get_payjoin_contact(cid) + uid = key_info.wallet.user + if not c or uid not in (c.requester_user_id, c.target_user_id): + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Not found.") + await set_payjoin_contact_label(cid, uid, data.label or "") + return {"status": "ok"} + + +@silnt_api_router.get("/api/v1/payjoin/payers") +async def api_payjoin_payers( + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + """My connected counterparties, for the invoice payer-picker. Only ACCEPTED + connections. Includes this user's private label (shown if set).""" + uid = key_info.wallet.user + pairs = await list_accepted_contacts_with_ids(uid) + labels = await get_payjoin_contact_labels(uid) + payers = [] + for p in pairs: + acct = await get_account(p["user_id"]) + if acct and acct.username: + payers.append({ + "user_id": p["user_id"], "username": acct.username, + "label": labels.get(p["contact_id"], ""), + }) + payers.sort(key=lambda x: (x["label"] or x["username"]).lower()) + return {"payers": payers} + + +@silnt_api_router.post("/api/v1/payjoin/invoices") +async def api_payjoin_create_invoice( + data: CreateInvoiceData, + key_info: WalletTypeInfo = Depends(require_trusted_device_admin), +): + """A (payee) creates a directed invoice for payer B. A picks their receiving + wallet + one contributed input + B (from the dropdown) + amount/memo.""" + payee_uid = key_info.wallet.user + + # A's receiving wallet + rd = await get_payjoin_descriptor(data.receiver_descriptor_id) + if not rd or rd.user_id != payee_uid: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Your wallet not found.") + + # resolve payer B + payer = await get_account_by_username(data.payer_username) + if not payer: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Payer username not found.") + if payer.id == payee_uid: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="You can't invoice yourself.") + + # payer must be an accepted connection (consent-based; no invoicing strangers) + connected = await list_accepted_contact_user_ids(payee_uid) + if payer.id not in set(connected): + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, + detail="You can only invoice a connected user. Send a connection request first.") + + # A's contributed input must not already be reserved + reserved = await get_reserved_outpoints(payee_uid) + ri = data.receiver_input + if f"{ri['txid']}:{ri['vout']}" in reserved: + raise HTTPException(status_code=HTTPStatus.CONFLICT, + detail="That input is already reserved by another pending PayJoin.") + + # A's payment address at next-unused receive index (avoid reuse) + host, port, tls, network = await _fulcrum_cfg(rd.network) + try: + pay_index = next_unused_receive_index(rd.descriptor, network, host, port, use_tls=tls) + except Exception as e: + logger.warning(f"payjoin: next-index sync failed, using 0: {e}") + pay_index = 0 + payment_address = derive_descriptor_address(rd.descriptor, network, 0, pay_index) + + payee_acct = await get_account(payee_uid) + payee_username = (payee_acct.username if payee_acct else None) or payee_uid + + inv = await create_payjoin_invoice( + payee_user_id=payee_uid, payee_username=payee_username, + payee_descriptor_id=rd.id, payee_input=data.receiver_input, + payment_address=payment_address, + payer_user_id=payer.id, payer_username=data.payer_username, + amount_sats=data.amount_sats, fee_rate=data.fee_rate, memo=data.memo, + ) + return inv.dict() + + +@silnt_api_router.get("/api/v1/payjoin/invoices") +async def api_payjoin_invoices( + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + """Open invoices directed to me (to pay).""" + uid = key_info.wallet.user + payable = await list_payjoin_invoices_for_payer(uid) + return {"payable": [r.dict() for r in payable]} + + +@silnt_api_router.post("/api/v1/payjoin/invoices/{rid}/pay") +async def api_payjoin_pay_invoice( + rid: str, data: PayInvoiceData, + key_info: WalletTypeInfo = Depends(require_trusted_device_admin), +): + """B (payer) pays an OPEN invoice: commits wallet + inputs. siLNt builds the + merged PSBT (B's inputs + A's pre-chosen input) and moves to CLAIMED. Both + then sign the same unsigned PSBT.""" + req = await get_payjoin_request(rid) + if not req or req.sender_user_id != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Invoice not found.") + if req.status != "OPEN": + raise HTTPException(status_code=HTTPStatus.CONFLICT, + detail=f"Invoice is no longer open (state {req.status}).") + + # B's wallet + sd = await get_payjoin_descriptor(data.sender_descriptor_id) + if not sd or sd.user_id != key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Your wallet not found.") + + # B's inputs must not be reserved + reserved = await get_reserved_outpoints(key_info.wallet.user) + clash = [f"{u['txid']}:{u['vout']}" for u in data.sender_inputs + if f"{u['txid']}:{u['vout']}" in reserved] + if clash: + raise HTTPException(status_code=HTTPStatus.CONFLICT, + detail="Some of your selected inputs are already reserved.") + + # A's wallet + pre-chosen input (stored at invoice creation) + rd = await get_payjoin_descriptor(req.receiver_descriptor_id) + if not rd: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Payee wallet missing.") + payee_input = json.loads(req.receiver_input) + + host, port, tls, network = await _fulcrum_cfg(rd.network) + try: + built = build_merged_payjoin( + sender_descriptor=sd.descriptor, # payer B + sender_inputs=data.sender_inputs, + receiver_descriptor=rd.descriptor, # payee A + receiver_input=payee_input, + network=network, + destination=req.payment_address, # A's address + amount=req.amount_sats, + fee_rate=req.fee_rate, + ) + except Exception as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=f"Build failed: {e}") + + await update_payjoin_request( + rid, status="CLAIMED", + sender_descriptor_id=sd.id, + sender_inputs=json.dumps(data.sender_inputs), + fee_sats=built["fee"], + unsigned_psbt=built["psbt_base64"], + ) + return {"status": "CLAIMED", "unsigned_psbt": built["psbt_base64"]} + + +# ── list requests ───────────────────────────────────────────────────────────── +@silnt_api_router.get("/api/v1/payjoin/requests") +async def api_payjoin_requests( + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + uid = key_info.wallet.user + incoming = await list_payjoin_requests_for_receiver(uid) + outgoing = await list_payjoin_requests_for_sender(uid) + try: + blindbit = await get_backend_config(DEFAULT_CONFIG_NETWORK) + mempool_base = blindbit.mempool_url or "https://mempool.space" + seen = set() + for r in [*incoming, *outgoing]: + if r.status == "BROADCAST" and r.txid and r.id not in seen: + seen.add(r.id) + try: + st = await get_tx_status(mempool_base, r.txid) + if st and st.get("confirmed"): + await update_payjoin_request(r.id, status="CONFIRMED") + r.status = "CONFIRMED" + except Exception: + pass # explorer hiccup — leave as BROADCAST, retry next fetch + except Exception: + pass # config/explorer unavailable — non-fatal, statuses unchanged + return { + "incoming": [r.dict() for r in incoming], + "outgoing": [r.dict() for r in outgoing], + } + + +@silnt_api_router.get("/api/v1/payjoin/requests/{rid}/unsigned") +async def api_payjoin_unsigned( + rid: str, key_info: WalletTypeInfo = Depends(require_trusted_device), +): + req = await get_payjoin_request(rid) + uid = key_info.wallet.user + if not req or uid not in (req.sender_user_id, req.receiver_user_id): + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Request not found.") + if not req.unsigned_psbt: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail="No unsigned PSBT yet — invoice not paid.") + return {"status": req.status, "unsigned_psbt": req.unsigned_psbt} + + +# ── either party submits their signed copy; broadcast when BOTH present ─────── +@silnt_api_router.post("/api/v1/payjoin/requests/{rid}/sign") +async def api_payjoin_sign( + rid: str, data: SignPayjoinData, + key_info: WalletTypeInfo = Depends(require_trusted_device_admin), +): + req = await get_payjoin_request(rid) + uid = key_info.wallet.user + if not req or uid not in (req.sender_user_id, req.receiver_user_id): + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Request not found.") + if req.status != "CLAIMED": + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail=f"Cannot sign in state {req.status}.") + + # store this party's signed copy in their slot (payee=receiver, payer=sender) + if uid == req.receiver_user_id: + await update_payjoin_request(rid, receiver_signed_psbt=data.signed_psbt) + else: + await update_payjoin_request(rid, sender_signed_psbt=data.signed_psbt) + + req = await get_payjoin_request(rid) + # still waiting on the other party? + if not (req.receiver_signed_psbt and req.sender_signed_psbt): + return {"status": "CLAIMED", "waiting_for_other": True} + + # both present → combine + broadcast + try: + result = combine_and_finalize([req.receiver_signed_psbt, req.sender_signed_psbt]) + except Exception as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=f"Combine failed: {e}") + if not result["finalized"]: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail=f"Not fully signed: {result.get('finalize_error')}") + + tx_hex = result["tx_hex"] + _sd = await get_payjoin_descriptor(req.sender_descriptor_id) + _net = _sd.network if _sd else "signet" + try: + txid = await _broadcast_via_mempool(tx_hex, _net) + except HTTPException: + await update_payjoin_request(rid, tx_hex=tx_hex) + raise + except Exception as e: + await update_payjoin_request(rid, tx_hex=tx_hex) + raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=f"Broadcast failed: {e}") + + await update_payjoin_request(rid, status="BROADCAST", tx_hex=tx_hex, txid=txid) + return {"status": "BROADCAST", "txid": txid, "waiting_for_other": False} + + +# ── cancel (payee cancels OPEN invoice) / abandon (payer backs out) ────────── +@silnt_api_router.post("/api/v1/payjoin/requests/{rid}/cancel") +async def api_payjoin_cancel( + rid: str, key_info: WalletTypeInfo = Depends(require_trusted_device), +): + req = await get_payjoin_request(rid) + uid = key_info.wallet.user + if not req or uid not in (req.sender_user_id, req.receiver_user_id): + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Request not found.") + if req.status in ("BROADCAST", "CANCELLED"): + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Already terminal.") + who = "payee" if uid == req.receiver_user_id else "payer" + await update_payjoin_request(rid, status="CANCELLED", reject_reason=f"cancelled by {who}") + return {"status": "CANCELLED"} + + +@silnt_api_router.get("/api/v1/admin/fulcrum/health") +async def api_fulcrum_health( + network: str = Query("signet"), + key_info: WalletTypeInfo = Depends(require_admin_key), +): + """ + Health = Fulcrum reachable AND in sync with the chain tip (mempool/esplora). + Mirrors the BlindBit health check. Returns up/down + whether heights diverge. + """ + cfg = await get_backend_config(network) + host = getattr(cfg, "fulcrum_host", "") or "" + port = int(getattr(cfg, "fulcrum_port", 50003) or 50003) + tls = bool(getattr(cfg, "fulcrum_tls", False)) + mp_url = (getattr(cfg, "mempool_url", "") or "").rstrip("/") + + if not host: + await notify_service_health_change("Fulcrum", False, "Fulcrum host not configured.") + return {"ok": False, "in_sync": False, "error": "Fulcrum host not configured.", + "fulcrum_height": None, "tip_height": None, "behind_by": None, "latency_ms": None} + + started = _time.monotonic() + try: + c = ElectrumClient(host, port, use_tls=tls) + c.connect() + ver = c.server_version() + fh = c.server_height() + c.close() + latency_ms = int((_time.monotonic() - started) * 1000) + except Exception as exc: + latency_ms = int((_time.monotonic() - started) * 1000) + await notify_service_health_change("Fulcrum", False, str(exc)[:120]) + return {"ok": False, "in_sync": False, "error": str(exc)[:200], + "fulcrum_height": None, "tip_height": None, "behind_by": None, + "latency_ms": latency_ms} + try: + fh_int = int(fh) + except (TypeError, ValueError): + fh_int = 0 + if not fh_int or fh_int <= 0: + await notify_service_health_change("Fulcrum", False, "No block height returned.") + return {"ok": False, "in_sync": False, "error": "Fulcrum returned no block height.", + "fulcrum_height": fh, "tip_height": None, "behind_by": None, + "latency_ms": latency_ms} + fh = fh_int + + # chain tip from mempool/esplora for the sync comparison + tip_height = None + if mp_url: + try: + async with httpx.AsyncClient(timeout=8.0, verify=False) as client: + rt = await client.get(f"{mp_url}/api/blocks/tip/height") + if rt.status_code == 200: + tip_height = int(rt.text.strip()) + except Exception: + tip_height = None + + if tip_height is None: + await notify_service_health_change("Fulcrum", True) + return {"ok": True, "in_sync": None, "error": "Could not fetch chain tip to compare.", + "fulcrum_height": fh, "tip_height": None, "behind_by": None, + "latency_ms": latency_ms, "server_version": ver} + + behind_by = tip_height - fh + in_sync = behind_by <= FULCRUM_SYNC_TOLERANCE + await notify_service_health_change("Fulcrum", True) + return { + "ok": True, "in_sync": in_sync, "error": None, + "fulcrum_height": fh, "tip_height": tip_height, "behind_by": behind_by, + "latency_ms": latency_ms, "server_version": ver, + } + +# ── SP send contacts (per-user private address book) ────────────────────────── +@silnt_api_router.get("/api/v1/contacts") +async def api_sp_contacts_list( + network: Optional[str] = Query(None), + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + # The address book is per-network; require it explicitly so a mainnet build + # can't be served a signet account's contacts (and vice versa). + if not network: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="A `network` query parameter is required (e.g. ?network=mainnet).", + ) + rows = await list_sp_contacts(key_info.wallet.user, network) + return {"contacts": [c.dict() for c in rows]} + + +@silnt_api_router.post("/api/v1/contacts") +async def api_sp_contacts_create( + data: CreateSpContactData, + network: Optional[str] = Query(None), + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + if not network: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="A `network` query parameter is required (e.g. ?network=mainnet).", + ) + value = (data.value or "").strip() + if not value: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Recipient is required.") + if "@" in value: + user, _, domain = value.partition("@") + if not user or not domain or "." not in domain: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Invalid BitMail name.") + elif not (value.startswith("sp1") or value.startswith("tsp1")): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Recipient must be a BitMail name (name@domain) or an SP address (sp1…/tsp1…).", + ) + try: + c = await create_sp_contact(key_info.wallet.user, data.label, value, network) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + return c.dict() + +@silnt_api_router.patch("/api/v1/contacts/{cid}") +async def api_sp_contacts_update( + cid: str, + data: UpdateSpContactData, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + try: + await update_sp_contact_label(cid, key_info.wallet.user, data.label) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + return {"ok": True} + + +@silnt_api_router.delete("/api/v1/contacts/{cid}") +async def api_sp_contacts_delete( + cid: str, + key_info: WalletTypeInfo = Depends(require_trusted_device), +): + await delete_sp_contact(cid, key_info.wallet.user) + return {"ok": True} + +# ── Admin: delete a user account ────────────────────────────────────────────── +async def _resolve_account(identifier: str): + """Resolve a username-or-email to (user_id, username). (None, None) if not found.""" + ident = (identifier or "").strip() + if not ident: + return (None, None) + try: + acct = await get_account_by_username(ident) + if acct: + return (acct.id, getattr(acct, "username", None) or ident) + except Exception: + pass + if "@" in ident: + uid, uname = await get_account_id_by_email(ident) + if uid: + return (uid, uname or ident) + return (None, None) + + +@silnt_api_router.get( + "/api/v1/admin/account/lookup", + dependencies=[Depends(require_trusted_device_admin)], +) +async def api_admin_account_lookup( + identifier: str, + key_info: WalletTypeInfo = Depends(require_trusted_device_admin), +): + require_admin(key_info) + user_id, username = await _resolve_account(identifier) + if not user_id: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="No such account.") + wallets = await get_silnt_wallets(user_id) + hr_addresses = await get_user_hr_addresses(user_id) + return { + "user_id": user_id, + "username": username, + "wallet_count": len(wallets), + "bitmail_addresses": hr_addresses, + "is_self": user_id == key_info.wallet.user, + } + + +@silnt_api_router.post( + "/api/v1/admin/account/delete", + dependencies=[Depends(require_trusted_device_admin)], +) +async def api_admin_account_delete( + data: AdminDeleteAccountData, + key_info: WalletTypeInfo = Depends(require_trusted_device_admin), +): + require_admin(key_info) + user_id, username = await _resolve_account(data.identifier) + if not user_id: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="No such account.") + if (data.confirm_username or "").strip() != (username or ""): + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail="Confirmation does not match the account username.") + if user_id == key_info.wallet.user: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail="You can't delete your own admin account from here.") + try: + if user_id == getattr(lnbits_settings, "super_user", None): + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail="The superuser account can't be deleted.") + except HTTPException: + raise + except Exception: + pass + if data.delete_bitmail: + try: + cf = await get_cloudflare_config() + if cf and getattr(cf, "api_token", "") and getattr(cf, "zone_id", ""): + for hr in await get_user_hr_addresses(user_id): + if "@" in hr: + try: + await delete_bip353_record(cf.api_token, cf.zone_id, cf.domain, hr.split("@", 1)[0]) + except Exception as e: + logger.warning(f"admin delete: BitMail DNS cleanup failed for {hr}: {e}") + except Exception as e: + logger.warning(f"admin delete: BitMail cleanup skipped: {e}") + try: + stats = await delete_all_silnt_data_for_user(user_id) + except Exception as e: + logger.error(f"admin delete: siLNt data removal failed for {user_id}: {e}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Could not remove the user's wallet data. Account not deleted.") + try: + await delete_account(user_id) + except Exception as e: + logger.error(f"admin delete: LNbits account removal failed for {user_id}: {e}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Wallet data removed, but the account could not be fully deleted.") + logger.info(f"admin {key_info.wallet.user} deleted account {user_id} ({username}): {stats}") + return {"deleted": True, "username": username, "wallet_ids": stats.get("wallet_ids", [])} + +# ── Admin: delete a user account ────────────────────────────────────────────── +@silnt_api_router.get( + "/api/v1/admin/accounts", + dependencies=[Depends(require_trusted_device_admin)], +) +async def api_admin_accounts_list( + network: str = Query("signet"), + key_info: WalletTypeInfo = Depends(require_trusted_device_admin), +): + require_admin(key_info) + out = [] + for uid in await list_silnt_user_ids_for_network(network): + acct = await get_account(uid) + # Skip orphaned references: siLNt rows whose LNbits account no longer + # exists (deleted user leaving stale device/descriptor rows). + if not acct: + continue + # get_account may return an object or a mapping depending on LNbits + # version — read defensively so an odd shape can't 500 the page. + uname = getattr(acct, "username", None) + email = getattr(acct, "email", None) + if uname is None and isinstance(acct, dict): + uname = acct.get("username") + email = acct.get("email") + wallets = await get_silnt_wallets(uid, network) + out.append({ + "user_id": uid, + "username": uname or uid, + "email": email or "", + "wallet_count": len(wallets), + "bitmail_addresses": await get_user_hr_addresses(uid), + "is_self": uid == key_info.wallet.user, + }) + out.sort(key=lambda a: (a["username"] or "").lower()) + return {"accounts": out} + +# ── Ntfy notifications config (admin only) ─────────────────────────────────── +@silnt_api_router.get("/api/v1/ntfy/config") +async def api_get_ntfy_config( + key_info: WalletTypeInfo = Depends(require_trusted_device_admin), +) -> NtfyConfig: + require_admin(key_info) + return await get_ntfy_config() + + +@silnt_api_router.put("/api/v1/ntfy/config") +async def api_update_ntfy_config( + data: NtfyConfig, + key_info: WalletTypeInfo = Depends(require_trusted_device_admin), +) -> NtfyConfig: + require_admin(key_info) + # Normalize: trim server URL, clean the topic list, validate priority. + data.server_url = (data.server_url or "https://ntfy.bitaurus.net").strip().rstrip("/") + data.topics = [t.strip() for t in (data.topics or []) if t and t.strip()] + if data.priority not in ("min", "low", "default", "high", "urgent"): + data.priority = "default" + if data.enabled and (not data.server_url or not data.topics): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="A server URL and at least one topic are required to enable ntfy.", + ) + return await update_ntfy_config(data) + + +@silnt_api_router.post("/api/v1/ntfy/test") +async def api_test_ntfy( + key_info: WalletTypeInfo = Depends(require_trusted_device_admin), +): + require_admin(key_info) + cfg = await get_ntfy_config() + if not cfg.enabled: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Ntfy is disabled.") + result = await send_ntfy_notification( + title="siLNt test notification", + message="If you can read this, ntfy notifications are working.", + tags=["white_check_mark"], + ) + if not result.get("sent"): + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, + detail=f"No notifications delivered. {result.get('errors') or result.get('skipped') or ''}", + ) + return result + +# ── Admin alerts (e.g. BitMail tampering) ──────────────────────────────────── +@silnt_api_router.get("/api/v1/admin/alerts") +async def api_admin_alerts_list( + include_acknowledged: bool = False, + key_info: WalletTypeInfo = Depends(require_trusted_device_admin), +): + require_admin(key_info) + alerts = await list_admin_alerts(include_acknowledged=include_acknowledged) + return { + "alerts": [a.dict() for a in alerts], + "open_count": await count_open_admin_alerts(), + } + + +@silnt_api_router.post("/api/v1/admin/alerts/{alert_id}/ack") +async def api_admin_alert_ack( + alert_id: str, + key_info: WalletTypeInfo = Depends(require_trusted_device_admin), +): + require_admin(key_info) + ok = await acknowledge_admin_alert(alert_id) + if not ok: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Alert not found.") + return {"acknowledged": True, "id": alert_id} + +_tamper_sweep_lock = asyncio.Lock() + + +async def _notify_and_mark_tamper(bitmail: str, detail: str) -> None: + """Send the tamper ntfy once and, on success, mark the open alert notified so + subsequent sweeps stay quiet. Kept separate so ntfy count tracks alert rows.""" + try: + _ntfy_res = await send_ntfy_notification( + title="BitMail tampering detected", + message=detail, + tags=["rotating_light"], + priority="urgent", + ) + logger.warning(f"tamper sweep: ntfy result for {bitmail}: {_ntfy_res}") + if _ntfy_res and _ntfy_res.get("sent"): + await mark_tamper_notified("bitmail_tamper", bitmail) + except Exception as e: + logger.warning(f"tamper sweep: ntfy failed for {bitmail}: {e}") + +async def run_bitmail_tamper_sweep() -> dict: + """ + Check EVERY BitMail siLNt issued: resolve its DNS TXT and compare to the SP + address we recorded. Any mismatch → an admin alert (once, until acknowledged) + + an urgent ntfy. This detects tampering with any user's BitMail proactively, + independent of whether anyone is sending to it. Best-effort — never raises. + Returns a small summary. + """ + # Prevent overlapping sweeps (scheduled loop + manual endpoint, or a slow + # run) from racing the check-then-create dedup and producing duplicate + # alerts/ntfys for the same bitmail. If one is already running, skip — it + # covers the same BitMails. + if _tamper_sweep_lock.locked(): + return {"checked": 0, "mismatches": 0, "skipped": "already_running"} + async with _tamper_sweep_lock: + return await _run_bitmail_tamper_sweep_inner() + +async def _run_bitmail_tamper_sweep_inner() -> dict: + try: + cf = await get_cloudflare_config() + our_domain = (getattr(cf, "domain", "") or "").strip().lower() + except Exception: + our_domain = "" + if not our_domain: + return {"checked": 0, "mismatches": 0, "skipped": "no_domain"} + + try: + issued = await list_approved_bitmails() + except Exception as e: + logger.error(f"tamper sweep: could not list issued BitMails: {e}") + return {"checked": 0, "mismatches": 0, "error": str(e)[:120]} + + checked = 0 + mismatches = 0 + for row in issued: + uname = (row.get("final_username") or "").strip() + expected = (row.get("sp_address") or "").strip() + if not uname or not expected: + continue + # If the owning wallet has been deleted, this is a stale record left over + # from a wallet removal. Purge it (request row + any alerts) and skip — a + # dead wallet must not keep generating tamper checks/alerts. This also + # self-heals orphans created before delete-time cleanup existed. + wid = (row.get("wallet_id") or "").strip() + if wid and not await get_silnt_wallet(wid): + try: + await delete_bip353_requests_for_wallet(wid) + await delete_admin_alerts_for_wallet(wid) + logger.info(f"tamper sweep: purged stale BitMail records for deleted wallet {wid}") + except Exception as e: + logger.warning(f"tamper sweep: could not purge stale records for {wid}: {e}") + continue + bitmail = f"{uname}@{our_domain}" + checked += 1 + try: + resolved = bip353_resolve(bitmail) + result = (resolved.get("result", "") or "").replace("bitcoin:?sp=", "").replace("sp=", "").strip() + except Exception: + # A resolution failure is not proof of tampering (DNS hiccup, record + # removed by the owner, etc.) — don't alert on it here. + continue + if not result: + continue + if result.lower() == expected.lower(): + # Resolves correctly — clear any stale open tamper alert for this + # bitmail (e.g. the DNS record was corrected after a prior alert). + try: + cleared = await resolve_open_alerts_for("bitmail_tamper", bitmail) + if cleared: + logger.warning(f"tamper sweep: cleared {cleared} stale alert(s) for {bitmail} (now matches)") + except Exception as e: + logger.warning(f"tamper sweep: could not clear stale alert for {bitmail}: {e}") + continue + if result.lower() != expected.lower(): + mismatches += 1 + detail = ( + f"{bitmail} resolves to {result} but siLNt issued it for {expected}. " + f"The DNS record may have been tampered with to redirect funds." + ) + # De-dupe on the tamper SIGNATURE (bitmail rogue address), including + # acknowledged rows, so dismissing an ongoing tamper doesn't resurrect it. + if await tamper_signature_alerted(bitmail, result): + continue + # No alert yet — create exactly one row, then notify for it. + try: + await create_admin_alert( + kind="bitmail_tamper", + severity="critical", + title=f"BitMail tampering: {bitmail}", + detail=detail, + meta=json.dumps({ + "bitmail": bitmail, + "resolved_sp": result, + "expected_sp": expected, + "user_id": row.get("user_id"), + "wallet_id": row.get("wallet_id"), + }), + ) + except Exception as e: + logger.error(f"tamper sweep: could not record alert for {bitmail}: {e}") + await _notify_and_mark_tamper(bitmail, detail) + return {"checked": checked, "mismatches": mismatches} + +async def probe_blindbit_health() -> None: + """Reachability probe for the BlindBit Oracle, callable from a background + loop (no auth). Fires the down/up ntfy via notify_service_health_change on a + state change. Best-effort — never raises.""" + try: + blindbit = await get_backend_config(DEFAULT_CONFIG_NETWORK) + bb_url = (blindbit.blindbit_url or "").rstrip("/") + if not bb_url: + await notify_service_health_change("BlindBit Oracle", False, "URL not configured.") + return + try: + async with httpx.AsyncClient(timeout=8.0, verify=False) as c: + r = await c.get(f"{bb_url}/info") + if r.status_code != 200: + await notify_service_health_change("BlindBit Oracle", False, f"HTTP {r.status_code}") + return + int(r.json().get("height")) # ensure it's a sane response + await notify_service_health_change("BlindBit Oracle", True) + except Exception as exc: + await notify_service_health_change("BlindBit Oracle", False, str(exc)[:120]) + except Exception as e: + logger.warning(f"probe_blindbit_health error: {e}") + + +async def probe_fulcrum_health() -> None: + """Reachability probe for Fulcrum, callable from a background loop (no auth). + Fires the down/up ntfy on a state change. Best-effort — never raises.""" + try: + cfg = await get_backend_config(DEFAULT_CONFIG_NETWORK) + host = getattr(cfg, "fulcrum_host", "") or "" + port = int(getattr(cfg, "fulcrum_port", 50001) or 50001) + tls = bool(getattr(cfg, "fulcrum_tls", False)) + if not host: + await notify_service_health_change("Fulcrum", False, "Fulcrum host not configured.") + return + try: + c = ElectrumClient(host, port, use_tls=tls) + c.connect() + c.server_version() + fh = c.server_height() + c.close() + except Exception as exc: + await notify_service_health_change("Fulcrum", False, str(exc)[:120]) + return + try: + fh_int = int(fh) + except (TypeError, ValueError): + fh_int = 0 + if not fh_int or fh_int <= 0: + await notify_service_health_change("Fulcrum", False, "No block height returned.") + return + await notify_service_health_change("Fulcrum", True) + except Exception as e: + logger.warning(f"probe_fulcrum_health error: {e}") + + +async def run_health_probes() -> None: + """Run both service probes once (for the background monitor loop).""" + await probe_blindbit_health() + await probe_fulcrum_health() \ No newline at end of file