diff --git a/Dockerfile b/Dockerfile index 9490504e..22476b37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,9 @@ RUN apt-get update -qq && \ libpq-dev \ curl \ build-essential \ - gnupg2 + gnupg2 \ + pkg-config \ + libsecp256k1-dev RUN python -m pip install --upgrade pip diff --git a/api/nostr.py b/api/nostr.py index 4fe356d9..64b50cb2 100644 --- a/api/nostr.py +++ b/api/nostr.py @@ -2,9 +2,9 @@ import pygeohash import hashlib import uuid -from secp256k1 import PrivateKey, PublicKey, ALL_FLAGS +from secp256k1 import PrivateKey from asgiref.sync import sync_to_async -from nostr_sdk import Keys, Client, EventBuilder, NostrSigner, Kind, Tag +from nostr_sdk import Keys, Client, EventBuilder, NostrSigner, Kind, Tag, PublicKey from api.models import Order from decouple import config @@ -113,23 +113,23 @@ class Nostr: return ["onchain", "lightning"] else: return ["lightning"] + return False def is_valid_public_key(public_key_hex): try: - public_key_bytes = bytes.fromhex(public_key_hex) - PublicKey(public_key_bytes, raw=True) + PublicKey.from_hex(public_key_hex) return True except Exception: return False def sign_message(text: str) -> str: try: - private_key = config("NOSTR_NSEC", cast=str) - privkey = PrivateKey( - bytes.fromhex(private_key), raw=True, ctx_flags=ALL_FLAGS + keys = Keys.parse(config("NOSTR_NSEC", cast=str)) + secret_key_hex = keys.secret_key().to_hex() + private_key = PrivateKey(bytes.fromhex(secret_key_hex)) + signature = private_key.schnorr_sign( + text.encode("utf-8"), bip340tag=None, raw=True ) - hashed_message = hashlib.sha256(text.encode("utf-8")).digest() - signature = privkey.schnorr_sign(hashed_message) return signature.hex() except Exception: diff --git a/api/views.py b/api/views.py index 98aec2b7..f06fa9c9 100644 --- a/api/views.py +++ b/api/views.py @@ -1046,9 +1046,6 @@ class ReviewView(APIView): @extend_schema(**ReviewViewSchema.post) def post(self, request): - if config("NOSTR_NSEC", cast=str, default="") == "": - return Response(status=status.HTTP_400_BAD_REQUEST) - serializer = self.serializer_class(data=request.data) if not serializer.is_valid(): diff --git a/docs/assets/schemas/api-latest.yaml b/docs/assets/schemas/api-latest.yaml index 9890a31b..5bf467a8 100644 --- a/docs/assets/schemas/api-latest.yaml +++ b/docs/assets/schemas/api-latest.yaml @@ -493,6 +493,10 @@ paths: - `17` - Maker lost dispute - `18` - Taker lost dispute + The client can use `cancel_status` to cancel the order only + if it is in the specified status. The server will + return an error without cancelling the trade otherwise. + Note that there are penalties involved for cancelling a order mid-trade so use this action carefully: @@ -652,6 +656,44 @@ paths: timestamp: '2022-09-13T14:32:40.591774Z' summary: Truncated example. Real response contains all the currencies description: '' + /api/review/: + post: + operationId: review_create + description: Generates the token necesary for reviews of robot's latest order + summary: Generates a review token + tags: + - review + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Review' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Review' + multipart/form-data: + schema: + $ref: '#/components/schemas/Review' + required: true + security: + - tokenAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Review' + description: '' + '400': + content: + application/json: + schema: + type: object + properties: + bad_request: + type: string + description: Reason for the failure + description: '' /api/reward/: post: operationId: reward_create @@ -1775,6 +1817,14 @@ components: * `3` - 3 * `4` - 4 * `5` - 5 + Review: + type: object + properties: + pubkey: + type: string + description: Robot's nostr hex pubkey + required: + - pubkey StatusEnum: enum: - 0 @@ -1975,8 +2025,33 @@ components: pattern: ^-?\d{0,3}(?:\.\d{0,3})?$ nullable: true cancel_status: - allOf: + nullable: true + description: |- + Status the order should have for it to be cancelled. + + * `0` - Waiting for maker bond + * `1` - Public + * `2` - Paused + * `3` - Waiting for taker bond + * `4` - Cancelled + * `5` - Expired + * `6` - Waiting for trade collateral and buyer invoice + * `7` - Waiting only for seller trade collateral + * `8` - Waiting only for buyer invoice + * `9` - Sending fiat - In chatroom + * `10` - Fiat sent - In chatroom + * `11` - In dispute + * `12` - Collaboratively cancelled + * `13` - Sending satoshis to buyer + * `14` - Successful trade + * `15` - Failed lightning network routing + * `16` - Wait for dispute resolution + * `17` - Maker lost dispute + * `18` - Taker lost dispute + oneOf: - $ref: '#/components/schemas/StatusEnum' + - $ref: '#/components/schemas/BlankEnum' + - $ref: '#/components/schemas/NullEnum' required: - action Version: diff --git a/tests/robots/1/nostr_pubkey b/tests/robots/1/nostr_pubkey new file mode 100644 index 00000000..3b3ac844 --- /dev/null +++ b/tests/robots/1/nostr_pubkey @@ -0,0 +1 @@ +aa19b79461dbd92673900cd50f51934ec648f8dbb79cb2e3e59929e798d41e86 \ No newline at end of file diff --git a/tests/robots/2/nostr_pubkey b/tests/robots/2/nostr_pubkey new file mode 100644 index 00000000..7c0aaf66 --- /dev/null +++ b/tests/robots/2/nostr_pubkey @@ -0,0 +1 @@ +b905e7adcee82cc7fe3ba05c8f0922c9098a872f71db4ba70c216e4396d73be8 \ No newline at end of file diff --git a/tests/robots/3/nostr_pubkey b/tests/robots/3/nostr_pubkey new file mode 100644 index 00000000..6af49e02 --- /dev/null +++ b/tests/robots/3/nostr_pubkey @@ -0,0 +1 @@ +86127e010ee323f30c7935e70393a9acef7f269b88cc3b1610e336becfbfc8f2 \ No newline at end of file diff --git a/tests/test_trade_pipeline.py b/tests/test_trade_pipeline.py index 82365d70..e9c0e9cd 100644 --- a/tests/test_trade_pipeline.py +++ b/tests/test_trade_pipeline.py @@ -965,6 +965,46 @@ class TradeTest(BaseAPITestCase): self.assert_order_logs(data["id"]) + def test_review_order(self): + """ + Tests a trade review token generation after the trade ends + """ + trade = Trade(self.client) + trade.publish_order() + + trade.get_review() + self.assertEqual(trade.response.status_code, 400) + + trade.take_order() + trade.take_order_third() + trade.lock_taker_bond() + + trade.get_review(trade.maker_index) + self.assertEqual(trade.response.status_code, 400) + trade.get_review(trade.taker_index) + self.assertEqual(trade.response.status_code, 400) + + trade.lock_escrow(trade.taker_index) + trade.submit_payout_address(trade.maker_index) + trade.confirm_fiat(trade.maker_index) + trade.confirm_fiat(trade.taker_index) + + trade.process_payouts(mine_a_block=True) + + trade.get_review(trade.maker_index) + self.assertEqual(trade.response.status_code, 200) + nostr_pubkey = read_file(f"tests/robots/{trade.maker_index}/nostr_pubkey") + data = trade.response.json() + self.assertEqual(data["pubkey"], nostr_pubkey) + self.assertIsInstance(data["token"], str) + + trade.get_review(trade.taker_index) + self.assertEqual(trade.response.status_code, 200) + nostr_pubkey = read_file(f"tests/robots/{trade.taker_index}/nostr_pubkey") + data = trade.response.json() + self.assertEqual(data["pubkey"], nostr_pubkey) + self.assertIsInstance(data["token"], str) + def test_cancel_public_order(self): """ Tests the cancellation of a public order @@ -993,6 +1033,9 @@ class TradeTest(BaseAPITestCase): f"❌ Hey {maker_nick}, you have cancelled your public order with ID {trade.order_id}.", ) + trade.get_review() + self.assertEqual(trade.response.status_code, 400) + def test_cancel_public_order_by_taker(self): """ Tests the cancellation of a public order by a pretaker @@ -1121,7 +1164,8 @@ class TradeTest(BaseAPITestCase): self.assertResponse(trade.response) self.assertEqual( - trade.response.json()["bad_request"], "This order has been cancelled by the maker" + trade.response.json()["bad_request"], + "This order has been cancelled by the maker", ) def test_cancel_order_different_cancel_status(self): @@ -1147,7 +1191,7 @@ class TradeTest(BaseAPITestCase): self.assertEqual( trade.response.json()["bad_request"], - f"Current order status is {Order.Status.PAU}, not {Order.Status.PUB}." + f"Current order status is {Order.Status.PAU}, not {Order.Status.PUB}.", ) # Cancel order to avoid leaving pending HTLCs after a successful test @@ -1278,6 +1322,9 @@ class TradeTest(BaseAPITestCase): f"😪 Hey {data['maker_nick']}, your order with ID {str(trade.order_id)} has expired without a taker.", ) + trade.get_review(trade.maker_index) + self.assertEqual(trade.response.status_code, 400) + def test_taken_order_expires(self): """ Tests the expiration of a public order @@ -1708,6 +1755,11 @@ class TradeTest(BaseAPITestCase): f"⚖️ Hey {data['taker_nick']}, a dispute has been opened on your order with ID {str(trade.order_id)}.", ) + trade.get_review(trade.maker_index) + self.assertEqual(trade.response.status_code, 400) + trade.get_review(trade.taker_index) + self.assertEqual(trade.response.status_code, 400) + def test_ticks(self): """ Tests the historical ticks serving endpoint after creating a contract diff --git a/tests/utils/trade.py b/tests/utils/trade.py index 3c74f6d9..4901df41 100644 --- a/tests/utils/trade.py +++ b/tests/utils/trade.py @@ -113,6 +113,16 @@ class Trade: headers = self.get_robot_auth(robot_index, first_encounter) self.response = self.client.get(path + params, **headers) + def get_review(self, robot_index=1): + """ + Generates coordinator's review signature + """ + path = reverse("review") + headers = self.get_robot_auth(robot_index) + nostr_pubkey = read_file(f"tests/robots/{robot_index}/nostr_pubkey") + body = {"pubkey": nostr_pubkey} + self.response = self.client.post(path, body, **headers) + @patch("api.tasks.send_notification.delay", send_notification) def cancel_order(self, robot_index=1, cancel_status=None): path = reverse("order")