diff --git a/.env-sample b/.env-sample index 66841738..4898512c 100644 --- a/.env-sample +++ b/.env-sample @@ -175,3 +175,6 @@ SLASHED_BOND_REWARD_SPLIT = 0.5 # Username for HTLCs escrows ESCROW_USERNAME = 'admin' + +#Social +NOSTR_NSEC = 'nsec1vxhs2zc4kqe0dhz4z2gfrdyjsrwf8pg3neeqx6w4nl8djfzdp0dqwd6rxh' diff --git a/api/logics.py b/api/logics.py index 7049e21c..8bec242a 100644 --- a/api/logics.py +++ b/api/logics.py @@ -8,7 +8,7 @@ from django.utils import timezone from api.lightning.node import LNNode from api.models import Currency, LNPayment, MarketTick, OnchainPayment, Order -from api.tasks import send_devfund_donation, send_notification +from api.tasks import send_devfund_donation, send_notification, nostr_send_order_event from api.utils import get_minning_fee, validate_onchain_address, location_country from chat.models import Message @@ -704,9 +704,9 @@ class Logics: if context["invoice_amount"] < MIN_SWAP_AMOUNT: context["swap_allowed"] = False - context[ - "swap_failure_reason" - ] = f"Order amount is smaller than the minimum swap available of {MIN_SWAP_AMOUNT} Sats" + context["swap_failure_reason"] = ( + f"Order amount is smaller than the minimum swap available of {MIN_SWAP_AMOUNT} Sats" + ) order.log( f"Onchain payment option was not offered: amount is smaller than the minimum swap available of {MIN_SWAP_AMOUNT} Sats", level="WARN", @@ -714,9 +714,9 @@ class Logics: return True, context elif context["invoice_amount"] > MAX_SWAP_AMOUNT: context["swap_allowed"] = False - context[ - "swap_failure_reason" - ] = f"Order amount is bigger than the maximum swap available of {MAX_SWAP_AMOUNT} Sats" + context["swap_failure_reason"] = ( + f"Order amount is bigger than the maximum swap available of {MAX_SWAP_AMOUNT} Sats" + ) order.log( f"Onchain payment option was not offered: amount is bigger than the maximum swap available of {MAX_SWAP_AMOUNT} Sats", level="WARN", @@ -741,9 +741,9 @@ class Logics: ) if not valid: context["swap_allowed"] = False - context[ - "swap_failure_reason" - ] = "Not enough onchain liquidity available to offer a swap" + context["swap_failure_reason"] = ( + "Not enough onchain liquidity available to offer a swap" + ) order.log( "Onchain payment option was not offered: onchain liquidity available to offer a swap", level="WARN", @@ -1019,6 +1019,8 @@ class Logics: order.log("Order expired while waiting for maker bond") order.log("Maker bond was cancelled") + nostr_send_order_event.delay(order_id=order.id) + return True, None # 2.a) When maker cancels after bond @@ -1039,6 +1041,8 @@ class Logics: order.log("Order cancelled by maker while public or paused") order.log("Maker bond was unlocked") + nostr_send_order_event.delay(order_id=order.id) + return True, None # 2.b) When maker cancels after bond and before taker bond is locked @@ -1058,6 +1062,8 @@ class Logics: order.log("Maker bond was unlocked") order.log("Taker bond was cancelled") + nostr_send_order_event.delay(order_id=order.id) + return True, None # 3) When taker cancels before bond @@ -1070,6 +1076,8 @@ class Logics: order.log("Taker cancelled before locking the bond") + nostr_send_order_event.delay(order_id=order.id) + return True, None # 4) When taker or maker cancel after bond (before escrow) @@ -1099,6 +1107,8 @@ class Logics: order.log("Maker bond was settled") order.log("Taker bond was unlocked") + nostr_send_order_event.delay(order_id=order.id) + return True, None # 4.b) When taker cancel after bond (before escrow) @@ -1121,6 +1131,8 @@ class Logics: order.log("Taker bond was settled") order.log("Maker bond was unlocked") + nostr_send_order_event.delay(order_id=order.id) + return True, None # 5) When trade collateral has been posted (after escrow) @@ -1136,6 +1148,9 @@ class Logics: order.log( f"Taker Robot({user.robot.id},{user.username}) accepted the collaborative cancellation" ) + + nostr_send_order_event.delay(order_id=order.id) + return True, None # if the taker had asked, and now the maker does: cancel order, return everything @@ -1144,6 +1159,9 @@ class Logics: order.log( f"Maker Robot({user.robot.id},{user.username}) accepted the collaborative cancellation" ) + + nostr_send_order_event.delay(order_id=order.id) + return True, None # Otherwise just make true the asked for cancel flags @@ -1181,6 +1199,8 @@ class Logics: order.update_status(Order.Status.CCA) send_notification.delay(order_id=order.id, message="collaborative_cancelled") + nostr_send_order_event.delay(order_id=order.id) + order.log("Order was collaboratively cancelled") order.log("Maker bond was unlocked") order.log("Taker bond was unlocked") @@ -1208,6 +1228,8 @@ class Logics: order.save() # update all fields + nostr_send_order_event.delay(order_id=order.id) + order.log(f"Order({order.id},{str(order)}) is public in the order book") return @@ -1350,6 +1372,9 @@ class Logics: except Exception: pass send_notification.delay(order_id=order.id, message="order_taken_confirmed") + + nostr_send_order_event.delay(order_id=order.id) + order.log( f"Contract formalized. Maker: Robot({order.maker.robot.id},{order.maker}). Taker: Robot({order.taker.robot.id},{order.taker}). API median price {order.currency.exchange_rate} {dict(Currency.currency_choices)[order.currency.currency]}/BTC. Premium is {order.premium}%. Contract size {order.last_satoshis} Sats" ) @@ -1741,11 +1766,15 @@ class Logics: order.log( f"Robot({user.robot.id},{user.username}) paused the public order" ) + + nostr_send_order_event.delay(order_id=order.id) elif order.status == Order.Status.PAU: order.update_status(Order.Status.PUB) order.log( f"Robot({user.robot.id},{user.username}) made public the paused order" ) + + nostr_send_order_event.delay(order_id=order.id) else: order.log( f"Robot({user.robot.id},{user.username}) tried to pause/unpause an order that was not public or paused", diff --git a/api/nostr.py b/api/nostr.py new file mode 100644 index 00000000..1027c13f --- /dev/null +++ b/api/nostr.py @@ -0,0 +1,98 @@ +import pygeohash +import hashlib +import uuid + +from asgiref.sync import sync_to_async +from nostr_sdk import Keys, Client, EventBuilder, NostrSigner, Kind, Tag +from api.models import Order +from decouple import config + + +class Nostr: + """Simple nostr events manager to be used as a cache system for clients""" + + async def send_order_event(self, order): + """Creates the event and sends it to the coordinator relay""" + + if config("NOSTR_NSEC", cast=str, default="") == "": + return + + print("Sending nostr event") + + # Initialize with coordinator Keys + keys = Keys.parse(config("NOSTR_NSEC", cast=str)) + signer = NostrSigner.keys(keys) + client = Client(signer) + + # Add relays and connect + await client.add_relays(["ws://localhost:7777"]) + await client.connect() + + robot_name = await self.get_robot_name(order) + currency = await self.get_robot_currency(order) + + event = EventBuilder( + Kind(38383), "", self.generate_tags(order, robot_name, currency) + ).to_event(keys) + await client.send_event(event) + print(f"Nostr event sent: {event.as_json()}") + + @sync_to_async + def get_robot_name(self, order): + return order.maker.username + + @sync_to_async + def get_robot_currency(self, order): + return str(order.currency) + + def generate_tags(self, order, robot_name, currency): + hashed_id = hashlib.md5( + f"{config("COORDINATOR_ALIAS", cast=str)}{order.id}".encode("utf-8") + ).hexdigest() + + tags = [ + Tag.parse(["d", str(uuid.UUID(hashed_id))]), + Tag.parse(["name", robot_name]), + Tag.parse(["k", "sell" if order.type == Order.Types.SELL else "buy"]), + Tag.parse(["f", currency]), + Tag.parse(["s", self.get_status_tag(order)]), + Tag.parse(["amt", "0"]), + Tag.parse( + ["fa"] + [str(order.amount)] + if not order.has_range + else [str(order.min_amount), str(order.max_amount)] + ), + Tag.parse(["pm"] + order.payment_method.split(" ")), + Tag.parse(["premium", str(order.premium)]), + Tag.parse( + [ + "source", + f"http://{config("HOST_NAME")}/order/{config("COORDINATOR_ALIAS", cast=str).lower()}/{order.id}", + ] + ), + Tag.parse(["expiration", str(int(order.expires_at.timestamp()))]), + Tag.parse(["y", "robosats", config("COORDINATOR_ALIAS", cast=str).lower()]), + Tag.parse(["n", str(config("NETWORK"))]), + Tag.parse(["layer"] + self.get_layer_tag(order)), + Tag.parse(["bond", str(order.bond_size)]), + Tag.parse(["z", "order"]), + ] + + if order.latitude and order.longitude: + tags.extend( + [Tag.parse(["g", pygeohash.encode(order.latitude, order.longitude)])] + ) + + return tags + + def get_status_tag(self, order): + if order.status == Order.Status.PUB: + return "pending" + else: + return "success" + + def get_layer_tag(self, order): + if order.type == Order.Types.SELL: + return ["onchain", "lightning"] + else: + return ["lightning"] diff --git a/api/tasks.py b/api/tasks.py index 656feb89..416a5847 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -1,3 +1,4 @@ +from asgiref.sync import async_to_sync from celery import shared_task from celery.exceptions import SoftTimeLimitExceeded @@ -251,6 +252,20 @@ def cache_market(): return +@shared_task(name="", ignore_result=True, time_limit=120) +def nostr_send_order_event(order_id=None): + if order_id: + from api.models import Order + from api.nostr import Nostr + + order = Order.objects.get(id=order_id) + + nostr = Nostr() + async_to_sync(nostr.send_order_event)(order) + + return + + @shared_task(name="send_notification", ignore_result=True, time_limit=120) def send_notification(order_id=None, chat_message_id=None, message=None): if order_id: diff --git a/docker-compose.yml b/docker-compose.yml index c8334bdb..18f369b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -226,6 +226,16 @@ services: volumes: - ./node/db:/var/lib/postgresql/data + strfry: + build: ./docker/strfry + container_name: strfry-dev + restart: unless-stopped + volumes: + - ./docker/strfry/strfry.conf:/etc/strfry.conf:ro + - ./docker/strfry/onion_urls.txt:/app/onion_urls.txt:ro + - ./node/strfry/db:/app/strfry-db:rw + network_mode: service:tor + # # Postgresql for CLN # postgres-cln: # image: postgres:14.2-alpine diff --git a/docker-tests.yml b/docker-tests.yml index 975747e8..07010344 100644 --- a/docker-tests.yml +++ b/docker-tests.yml @@ -27,6 +27,7 @@ services: - "9998:9998" - "5432:5432" - "6379:6379" + - "7777:7777" volumes: - bitcoin:/bitcoin/.bitcoin/ - ./tests/bitcoind/entrypoint.sh:/entrypoint.sh @@ -182,7 +183,7 @@ services: # celery-worker: # image: backend-image # pull_policy: never - # container_name: celery-worker + # container_name: test-celery-worker # restart: always # environment: # DEVELOPMENT: True diff --git a/docker/strfry/Dockerfile b/docker/strfry/Dockerfile new file mode 100644 index 00000000..86a2b083 --- /dev/null +++ b/docker/strfry/Dockerfile @@ -0,0 +1,41 @@ +FROM ubuntu:jammy +ENV TZ=Europe/London + +RUN apt update && apt install -y --no-install-recommends \ + git g++ make pkg-config libtool ca-certificates \ + libssl-dev zlib1g-dev liblmdb-dev libflatbuffers-dev \ + libsecp256k1-dev libzstd-dev + +# setup app +RUN git clone https://github.com/KoalaSat/strfry /app + +WORKDIR /app + +RUN git submodule update --init +RUN make setup-golpe +RUN make clean +RUN make -j4 + +RUN apt update && apt install -y --no-install-recommends \ + liblmdb0 libflatbuffers1 libsecp256k1-0 libb2-1 libzstd1 torsocks cron\ + && rm -rf /var/lib/apt/lists/* + +RUN echo "TorAddress 127.0.0.1" >> /etc/tor/torsocks.conf +RUN echo "TorPort 9050" >> /etc/tor/torsocks.conf + +# Setting up crontab +COPY crontab /etc/cron.d/crontab +RUN chmod 0644 /etc/cron.d/crontab +RUN crontab /etc/cron.d/crontab + +# Setting up entrypoints +COPY sync.sh /etc/strfry/sync.sh +COPY entrypoint.sh /etc/strfry/entrypoint.sh + +RUN chmod +x /etc/strfry/entrypoint.sh +RUN chmod +x /etc/strfry/sync.sh + +#Setting up logs +RUN touch /var/log/cron.log && chmod 0644 /var/log/cron.log + +ENTRYPOINT ["/etc/strfry/entrypoint.sh"] diff --git a/docker/strfry/crontab b/docker/strfry/crontab new file mode 100644 index 00000000..fb04c491 --- /dev/null +++ b/docker/strfry/crontab @@ -0,0 +1,24 @@ +# Edit this file to introduce tasks to be run by cron. +# +# Each task to run has to be defined through a single line +# indicating with different fields when the task will be run +# and what command to run for the task +# +# To define the time you can provide concrete values for +# minute (m), hour (h), day of month (dom), month (mon), +# and day of week (dow) or use '*' in these fields (for 'any'). +# +# Notice that tasks will be started based on the cron's system +# daemon's notion of time and timezones. +# +# Output of the crontab jobs (including errors) is sent through +# email to the user the crontab file belongs to (unless redirected). +# +# For example, you can run a backup of all your user accounts +# at 5 a.m every week with: +# 0 5 * * 1 tar -zcf /var/backups/home.tgz /home/ +# +# For more information see the manual pages of crontab(5) and cron(8) +# +# m h dom mon dow command +*/1 * * * * torsocks /etc/strfry/sync.sh >> /var/log/cron.log 2>&1 diff --git a/docker/strfry/entrypoint.sh b/docker/strfry/entrypoint.sh new file mode 100755 index 00000000..efb3062f --- /dev/null +++ b/docker/strfry/entrypoint.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +cron -f -l 8 & tail -f /var/log/cron.log & /app/strfry relay diff --git a/docker/strfry/onion_urls.txt b/docker/strfry/onion_urls.txt new file mode 100644 index 00000000..92abf187 --- /dev/null +++ b/docker/strfry/onion_urls.txt @@ -0,0 +1,4 @@ +ws://testraliar7xkhos2gipv2k65obykofb4jqzl5l4danfryacifi4t7qd.onion/nostr +ws://jpp3w5tpxtyg6lifonisdszpriiapszzem4wod2zsdweyfenlsxeoxid.onion/nostr +ws://ghbtv7lhoyhomyir4xvxaeyqgx4ylxksia343jaat3njqqlkqpdjqcyd.onion/nostr +ws://wsjyhbashc4zrrex6vijpryujggbka5plry2o62dxqoz3pxinblnj4ad.onion/nostr \ No newline at end of file diff --git a/docker/strfry/strfry.conf b/docker/strfry/strfry.conf new file mode 100644 index 00000000..01fb191a --- /dev/null +++ b/docker/strfry/strfry.conf @@ -0,0 +1,138 @@ +## +## Default strfry config +## + +# Directory that contains the strfry LMDB database (restart required) +db = "/app/strfry-db/" + +dbParams { + # Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required) + maxreaders = 256 + + # Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required) + mapsize = 10995116277760 + + # Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required) + noReadAhead = false +} + +events { + # Maximum size of normalised JSON, in bytes + maxEventSize = 65536 + + # Events newer than this will be rejected + rejectEventsNewerThanSeconds = 900 + + # Events older than this will be rejected + rejectEventsOlderThanSeconds = 94608000 + + # Ephemeral events older than this will be rejected + rejectEphemeralEventsOlderThanSeconds = 60 + + # Ephemeral events will be deleted from the DB when older than this + ephemeralEventsLifetimeSeconds = 300 + + # Maximum number of tags allowed + maxNumTags = 2000 + + # Maximum size for tag values, in bytes + maxTagValSize = 1024 +} + +relay { + # Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required) + bind = "0.0.0.0" + + # Port to open for the nostr websocket protocol (restart required) + port = 7777 + + # Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required) + nofiles = 1000000 + + # HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case) + realIpHeader = "" + + info { + # NIP-11: Name of this server. Short/descriptive (< 30 characters) + name = "Robosats" + + # NIP-11: Detailed information about relay, free-form + description = "Federation cache system." + + # NIP-11: Administrative nostr pubkey, for contact purposes + pubkey = "" + + # NIP-11: Alternative administrative contact (email, website, etc) + contact = "" + } + + # Maximum accepted incoming websocket frame size (should be larger than max event) (restart required) + maxWebsocketPayloadSize = 131072 + + # Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required) + autoPingSeconds = 55 + + # If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy) + enableTcpKeepalive = false + + # How much uninterrupted CPU time a REQ query should get during its DB scan + queryTimesliceBudgetMicroseconds = 10000 + + # Maximum records that can be returned per filter + maxFilterLimit = 500 + + # Maximum number of subscriptions (concurrent REQs) a connection can have open at any time + maxSubsPerConnection = 3 + + writePolicy { + # If non-empty, path to an executable script that implements the writePolicy plugin logic + plugin = "" + } + + compression { + # Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required) + enabled = true + + # Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required) + slidingWindow = false + } + + logging { + # Dump all incoming messages + dumpInAll = false + + # Dump all incoming EVENT messages + dumpInEvents = false + + # Dump all incoming REQ/CLOSE messages + dumpInReqs = false + + # Log performance metrics for initial REQ database scans + dbScanPerf = false + + # Log reason for invalid event rejection? Can be disabled to silence excessive logging + invalidEvents = true + } + + numThreads { + # Ingester threads: route incoming requests, validate events/sigs (restart required) + ingester = 3 + + # reqWorker threads: Handle initial DB scan for events (restart required) + reqWorker = 3 + + # reqMonitor threads: Handle filtering of new events (restart required) + reqMonitor = 3 + + # negentropy threads: Handle negentropy protocol messages (restart required) + negentropy = 2 + } + + negentropy { + # Support negentropy protocol messages + enabled = true + + # Maximum records that sync will process before returning an error + maxSyncEvents = 1000000 + } +} diff --git a/docker/strfry/sync.sh b/docker/strfry/sync.sh new file mode 100755 index 00000000..8c476172 --- /dev/null +++ b/docker/strfry/sync.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +filters='{"kinds":[38383]}' + +while IFS= read -r line; do + /app/strfry --config /etc/strfry.conf sync ${line} --filter "$filters" --dir both +done < /app/onion_urls.txt diff --git a/nodeapp/coordinators/exp/locations.conf b/nodeapp/coordinators/exp/locations.conf index 7be66e85..6a31d01c 100644 --- a/nodeapp/coordinators/exp/locations.conf +++ b/nodeapp/coordinators/exp/locations.conf @@ -37,6 +37,14 @@ location /mainnet/exp/ws/ { proxy_set_header Host $host; } +location /mainnet/exp/nostr/ { + proxy_pass http://mainnet_exp/nostr/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; +} + # Experimental Coordinator Testnet Locations location /test/exp/static/assets/avatars/ { proxy_pass http://testnet_exp/static/assets/avatars/; diff --git a/nodeapp/coordinators/lake/locations.conf b/nodeapp/coordinators/lake/locations.conf index d45ea193..8c91b44a 100644 --- a/nodeapp/coordinators/lake/locations.conf +++ b/nodeapp/coordinators/lake/locations.conf @@ -37,6 +37,14 @@ location /mainnet/lake/ws/ { proxy_set_header Host $host; } +location /mainnet/lake/nostr/ { + proxy_pass http://mainnet_lake/nostr/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; +} + # TheBigLake Coordinator Testnet Locations location /test/lake/static/assets/avatars/ { proxy_pass http://testnet_lake/static/assets/avatars/; diff --git a/nodeapp/coordinators/satstralia/locations.conf b/nodeapp/coordinators/satstralia/locations.conf index 828960f8..4d17e5da 100644 --- a/nodeapp/coordinators/satstralia/locations.conf +++ b/nodeapp/coordinators/satstralia/locations.conf @@ -37,6 +37,14 @@ location /mainnet/satstralia/ws/ { proxy_set_header Host $host; } +location /mainnet/satstralia/nostr/ { + proxy_pass http://mainnet_satstralia/nostr/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; +} + # Satstralia Coordinator Testnet Locations location /test/satstralia/static/assets/avatars/ { proxy_pass http://testnet_satstralia/static/assets/avatars/; diff --git a/nodeapp/coordinators/temple/locations.conf b/nodeapp/coordinators/temple/locations.conf index 6339b5ce..6f28cd9c 100644 --- a/nodeapp/coordinators/temple/locations.conf +++ b/nodeapp/coordinators/temple/locations.conf @@ -37,6 +37,14 @@ location /mainnet/temple/ws/ { proxy_set_header Host $host; } +location /mainnet/temple/nostr/ { + proxy_pass http://mainnet_temple/nostr/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; +} + # Temple of Sats Coordinator Testnet Locations location /test/temple/static/assets/avatars/ { proxy_pass http://testnet_temple/static/assets/avatars/; diff --git a/nodeapp/coordinators/veneto/locations.conf b/nodeapp/coordinators/veneto/locations.conf index 366dee58..d5b38738 100644 --- a/nodeapp/coordinators/veneto/locations.conf +++ b/nodeapp/coordinators/veneto/locations.conf @@ -37,6 +37,14 @@ location /mainnet/veneto/ws/ { proxy_set_header Host $host; } +location /mainnet/veneto/nostr/ { + proxy_pass http://mainnet_veneto/nostr/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; +} + # BitcoinVeneto Coordinator Testnet Locations location /test/veneto/static/assets/avatars/ { proxy_pass http://testnet_veneto/static/assets/avatars/; diff --git a/nodeapp/nginx.conf b/nodeapp/nginx.conf index 6b0eedd5..a36ae165 100644 --- a/nodeapp/nginx.conf +++ b/nodeapp/nginx.conf @@ -64,6 +64,14 @@ http { autoindex on; } + location /nostr { + proxy_pass http://127.0.0.1:7777; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + } + location = /favicon.ico { alias /usr/src/robosats/static/assets/images/favicon-96x96.png; } diff --git a/requirements.txt b/requirements.txt index a48214ba..b3d47ca1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,3 +28,6 @@ drf-spectacular==0.27.2 drf-spectacular-sidecar==2024.7.1 django-cors-headers==4.4.0 base91==1.0.1 +nostr-sdk==0.32.2 +pygeohash==1.2.0 +asgiref == 3.8.1