diff --git a/.env-sample b/.env-sample index cfb09671..66841738 100644 --- a/.env-sample +++ b/.env-sample @@ -58,6 +58,12 @@ SECRET_KEY = 'django-insecure-6^&6uw$b5^en%(cu2kc7_o)(mgpazx#j_znwlym0vxfamn2uo- # e.g. robotestagw3dcxmd66r4rgksb4nmmr43fh77bzn2ia2eucduyeafnyd.onion ONION_LOCATION = '' +# Geoblocked countries (will reject F2F trades). +# List of A3 country codes (see fhttps://en.wikipedia.org/wiki/ISO_3166-1_alpha-3) +# Leave empty '' to allow all countries. +# Example 'NOR,USA,CZE'. +GEOBLOCKED_COUNTRIES = 'ABW,AFG,AGO' + # Link to robosats alternative site (shown in frontend in statsfornerds so users can switch mainnet/testnet) ALTERNATIVE_SITE = 'RoboSats6tkf3eva7x2voqso3a5wcorsnw34jveyxfqi2fu7oyheasid.onion' ALTERNATIVE_NAME = 'RoboSats Mainnet' diff --git a/api/logics.py b/api/logics.py index 54918014..06d402ba 100644 --- a/api/logics.py +++ b/api/logics.py @@ -1,7 +1,7 @@ import math from datetime import timedelta -from decouple import config +from decouple import config, Csv from django.contrib.auth.models import User from django.db.models import Q, Sum from django.utils import timezone @@ -9,7 +9,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.utils import get_minning_fee, validate_onchain_address +from api.utils import get_minning_fee, validate_onchain_address, location_country from chat.models import Message FEE = float(config("FEE")) @@ -29,6 +29,8 @@ MAX_MINING_NETWORK_SPEEDUP_EXPECTED = float( config("MAX_MINING_NETWORK_SPEEDUP_EXPECTED") ) +GEOBLOCKED_COUNTRIES = config("GEOBLOCKED_COUNTRIES", cast=Csv(), default=[]) + class Logics: @classmethod @@ -137,6 +139,19 @@ class Logics: return True, None + @classmethod + def validate_location(cls, order) -> bool: + if not (order.latitude or order.longitude): + return True, None + + country = location_country(order.longitude, order.latitude) + if country in GEOBLOCKED_COUNTRIES: + return False, { + "bad_request": f"The coordinator does not support orders in {country}" + } + else: + return True, None + def validate_amount_within_range(order, amount): if amount > float(order.max_amount) or amount < float(order.min_amount): return False, { diff --git a/api/utils.py b/api/utils.py index 85b5dbcf..13fad5b0 100644 --- a/api/utils.py +++ b/api/utils.py @@ -479,6 +479,33 @@ def is_valid_token(token: str) -> bool: return all(c in charset for c in token) +def location_country(lon: float, lat: float) -> str: + """ + Returns the country code of a lon/lat location + """ + + from shapely.geometry import shape, Point + from shapely.prepared import prep + + # Load the GeoJSON data from a local file + with open("frontend/static/assets/geo/countries-coastline-10km.geo.json") as f: + countries_geojeson = json.load(f) + + # Prepare the countries for reverse geocoding + countries = {} + for feature in countries_geojeson["features"]: + geom = feature["geometry"] + country_code = feature["properties"]["A3"] + countries[country_code] = prep(shape(geom)) + + point = Point(lon, lat) + for country_code, geom in countries.items(): + if geom.contains(point): + return country_code + + return "unknown" + + def objects_to_hyperlinks(logs: str) -> str: """ Parses strings that have Object(ID,NAME) that match API models. diff --git a/api/views.py b/api/views.py index 53e15292..78bd275c 100644 --- a/api/views.py +++ b/api/views.py @@ -166,6 +166,10 @@ class MakerView(CreateAPIView): if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) + valid, context = Logics.validate_location(order) + if not valid: + return Response(context, status.HTTP_400_BAD_REQUEST) + order.save() order.log( f"Order({order.id},{order}) created by Robot({request.user.robot.id},{request.user})" diff --git a/requirements.txt b/requirements.txt index 3fdd9276..b6b89c85 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,7 @@ psycopg2==2.9.9 SQLAlchemy==2.0.16 django-import-export==3.3.8 requests[socks] +shapely==2.0.4 python-gnupg==0.5.2 daphne==4.1.0 drf-spectacular==0.27.2 diff --git a/tests/test_trade_pipeline.py b/tests/test_trade_pipeline.py index 4205b98c..32a39c9e 100644 --- a/tests/test_trade_pipeline.py +++ b/tests/test_trade_pipeline.py @@ -239,6 +239,38 @@ class TradeTest(BaseAPITestCase): self.assertIsNone(data["taker"], "New order's taker is not null") self.assert_order_logs(data["id"]) + def test_make_order_on_blocked_country(self): + """ + Test the creation of an F2F order on a geoblocked location + """ + trade = Trade( + self.client, + # latitude and longitud in Aruba. One of the countries blocked in the example conf. + maker_form={ + "type": 0, + "currency": 1, + "has_range": True, + "min_amount": 21, + "max_amount": 101.7, + "payment_method": "Advcash Cash F2F", + "is_explicit": False, + "premium": 3.34, + "public_duration": 69360, + "escrow_duration": 8700, + "bond_size": 3.5, + "latitude": -11.8014, # Angola AGO + "longitude": 17.3575, + }, + ) # init of Trade calls make_order() with the default maker form. + data = trade.response.json() + + self.assertEqual(trade.response.status_code, 400) + self.assertResponse(trade.response) + + self.assertEqual( + data["bad_request"], "The coordinator does not support orders in AGO" + ) + def test_get_order_created(self): """ Tests the creation of an order and the first request to see details, diff --git a/tests/utils/trade.py b/tests/utils/trade.py index b00d0ae7..16b9b89d 100644 --- a/tests/utils/trade.py +++ b/tests/utils/trade.py @@ -98,8 +98,8 @@ class Trade: response = self.client.post(path, maker_form, **headers) + self.response = response if response.status_code == 201: - self.response = response self.order_id = response.json()["id"] def get_order(self, robot_index=1, first_encounter=False):